Enforcing authentication within an application using the [Authorize] attribute is pretty simple once you've done it a few times, but setting up full 2FA using an authenticator app felt like a dark art, the sort of thing best being left to the likes of Azure authentication or Auth0.
In hindsight, implementing in this way is pretty simple.
- Provide a QR code that a user can scan.
- Test that it works.
- Intercept completion of login and prompt users for an OTP code.
QR Code generation
The QR code scanned within the authenticator app follows a standard URL format using the otpauth://
protocol, it encodes a secret which is used by the authenticator when generating the login code.
You could use a NuGet package to generate the full URL, but I wanted closer control over the issuer properties so I ended up encoding these myself:
public const string Issuer = "eMarketing Portal";
public const string IssuerWebsite = "emarketingportal";
public string GenerateOtpUrl(string emailAddress, string userSecret)
{
var encodedIssuer = Uri.EscapeDataString(IssuerWebsite);
var encodedLabel = Uri.EscapeDataString($"{Issuer}:{emailAddress}");
return $"otpauth://totp/{encodedLabel}?secret={userSecret}&issuer={encodedIssuer}";
}
This is the bare minimum required to generate the OTP code. The one drawback is that there's no way to tell the authenticator app which logo to display, I'd split out the issuer name and website domain into separate fields to try to facilitate this, but to no avail.
There are other optional properties that we can add, these are somewhat dependent on your security requirements:
Algorithm
The encryption method used generating the OTP code. Defaults toSHA1
. Other alternatives areSHA256
andSHA512
.Digits
The number of digits within the generated code. Defaults to6
.Period
The OTP expiration period in seconds. Defaults to a value of 30.
I opted to generate the secret and QR code server-side as an additional security precaution. In my case, I used the QRCoder NuGet package to generate the code as this gave me the colour coding options I was looking for and allowed me to embed a logo within the generated image, but really anything will do.
public string GenerateBase64QrCode(string otpUri)
{
using var qrGenerator = new QRCodeGenerator();
using var qrCodeData = qrGenerator.CreateQrCode(otpUri, QRCodeGenerator.ECCLevel.H);
using var qrCode = new QRCode(qrCodeData);
var logo = new Bitmap(new MemoryStream(Resources.logo_eM_square));
using var qrImage = qrCode.GetGraphic(
pixelsPerModule: 20,
darkColor: Color.FromArgb(45, 78, 118),
lightColor: Color.White,
icon: logo,
iconSizePercent: 40,
20
);
using var ms = new MemoryStream();
qrImage.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
var qrBytes = ms.ToArray();
string base64Image = Convert.ToBase64String(qrBytes);
return $"data:image/png;base64,{base64Image}";
}
Initial test
It's common to force the user to test their OTP code before enabling it within the application. This is all about protecting ourselves and giving the user piece of mind - if they've validated a code from their authenticator app at the point of configuring 2FA then they know it works.
As developers, we already know that everything will work as long as the instructions have been followed, but by adding extra validation we give our users a greater level of confidence.
I'm validating my users OTP code using OtpNet, but as with the QR code, any similar package will do the same job.
public bool ValidateUserOtp(string secret, string otpCode)
{
var totp = new Totp(Base32Encoding.ToBytes(secret));
return totp.VerifyTotp(otpCode, out _, VerificationWindow.RfcSpecifiedNetworkDelay);
}
Trigger on login
Finally, we want to make sure that we trigger validation of the users OTP code every time they login, we'll want to make sure that:
- The OTP verification screen is displayed immediately after the user logs in.
- That there's no way of circumventing the OTP verification.
- That we don't show the OTP verification screen when inappropriate (i.e. after resuming a browser session when already authenticated).
The first step is to track when the user has logged in, as soon as a user has authenticated by conventional means, we'll record their last login date.
var user = await userService.GetUserForAuthAsync(context.Principal);
if (user != null)
{
await userService.UpdateLastLogin(
user,
context.Principal.Claims.Select(x => new KeyValuePair<string, string>(x.Type, x.Value)).ToList());
}
Next, we'll want to redirect users to the OTP screen after login, but we'll also need to cover off other scenarios - for example, a user logs in, but doesn't complete the OTP screen, returning later.
To handle this, we'll want to intercept user navigation and force a redirect until the OTP code has been provided, or the user logs out. We can do this in .Net Core by adding app.UseMiddleware
to program.cs.
When we configure this command, we pass it a class containing an Invoke function:
public class UserIdentificationMiddleware(RequestDelegate next)
{
public async Task Invoke(HttpContext context)
{
// do stuff
await next(context);
}
}
In this example we can check the request path being accessed by querying context.Request.Path.Value
, assuming that the user isn't already accessing the OTP authentication page, or something like the logout screen then we can execute something like this:
var user = await userService.GetUserForAuthAsync(context.User);
var isAuthenticated = context.User?.Identity?.IsAuthenticated ?? false;
if (isAuthenticated &&
user.OtpEnabled && // has the user enabled 2FA?
(user.LastLoginDate.Value > user.LastOtpVerificationDate.Value))
{
context.Response.Redirect("/VerifyLogin");
return;
}
await next(context);
In addition to recording the users last login date, we're also recording the last time they authenticated their OTP code, we can use this alongside some server-side caching to check which event occurred last and redirect the user accordingly.
Once the user has provided their OTP password, we update the last verification date and the user can navigate as normal.
If we wanted to, we could take this approach a step further to force users to complete their account configuration before using a product or by redirecting to an error page if the account has been disabled.
Top comments (0)