
This post was inspired by Scott Brady's recent post on implementing "passwordless authentication" using ASP.NET Core Identity.. In this post I show how to implement his "optimisation" suggestions to reduce the lifetime of "magic link" tokens.
I start by providing some some background on the use case, but I strongly suggest reading Scott's post first if you haven't already, as mine builds strongly on his. I'll show:
- How to change the default lifetime of Data-Protection based tokens
- How to create a custom TOTP-based token provider
- How to create a custom Data-Protection based token provider
I'll start with the scenario: passwordless authentication.
Passwordless authentication using ASP.NET Core Identity
Scott's post describes how to recreate a login workflow similar to that of Slack's mobile app, or Medium:
Instead of providing a password, you enter your email and they send you a magic link:
Clicking the link automatically, logs you into the app. In nhis post, Scott shows how you can recreate the "magic link" login workflow using ASP.NET Core Identity. In this post, I want to address the very final section in his post, titled Optimisations:Existing Token Lifetime.
Scott points out that the implementation he provided uses the default token provider, the DataProtectorTokenProvider
to generate tokens, which generates large, long-lived tokens, something like the following:
CfDJ8GbuL4IlniBKrsiKWFEX/Ne7v/fPz9VKnIryTPWIpNVsWE5hgu6NSnpKZiHTGZsScBYCBDKx/
oswum28dUis3rVwQsuJd4qvQweyvg6vxTImtXSSBWC45sP1cQthzXodrIza8MVrgnJSVzFYOJvw/V
ZBKQl80hsUpgZG0kqpfGeeYSoCQIVhm4LdDeVA7vJ+Fn7rci3hZsdfeZydUExnX88xIOJ0KYW6UW+
mZiaAG+Vd4lR+Dwhfm/mv4cZZEJSoEw==
By default, these tokens last for 24 hours. For a passwordless authentication workflow, that's quite a lot longer than we'd like. Medium uses a 15 minute expiry for example.
Scott describes several options you could use to solve this:
- Change the default lifetime for all tokens that use the default token provider
- Use a different token provider, for example one of the TOTP-based providers
- Create a custom data-protection base token provider with a different token lifetime
All three of these approaches work, so I'll discuss each of them in turn.
Changing the default token lifetime
When you generate a token in ASP.NET Core Identity, by default you will use the DataProtectorTokenProvider
. We'll take a closer look at this class shortly, but for now it's sufficient to know it's used by workflows such as password reset (when you click the "forgot your password?" link) and for email confirmation.
The DataProtectorTokenProvider
depends on a DataProtectionTokenProviderOptions
object which has a TokenLifespan
property:
public class DataProtectionTokenProviderOptions
{
public string Name { get; set; } = "DataProtectorTokenProvider";
public TimeSpan TokenLifespan { get; set; } = TimeSpan.FromDays(1);
}
This property defines how long tokens generated by the provider are valid for. You can change this value using the standard ASP.NET Core Options framework inside your Startup.ConfigureServices
method:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.Configure<DataProtectionTokenProviderOptions>(
x => x.TokenLifespan = TimeSpan.FromMinutes(15));
// other services configuration
}
public void Configure() { /* pipeline config */ }
}
In this example, I've configured the token lifespan to be 15 minutes using a lambda, but you could also configure it by binding to IConfiguration
etc.
The downside to this approach, is that you've now reduced the token lifetime for all workflows. 15 minutes might be fine for password reset and passwordless login, but it's potentially too short for email confirmation, so you might run into issues with lots of rejected tokens if you choose to go this route.
Using a different provider
As well as the default DataProtectorTokenProvider
, ASP.NET Core Identity uses a variety of TOTP-based providers for generating short multi-factor authentication codes. For example, it includes providers for sending codes via email or via SMS. These providers both use the base TotpSecurityStampBasedTokenProvider
to generate their tokens. TOTP codes are typically very short-lived, so seem like they would be a good fit for the passwordless login scenario.
Given we're emailing the user a short-lived token for signing in, the EmailTokenProvider
might seem like a good choice for our paswordless login. But the EmailTokenProvider
is designed for providing 2FA tokens, and you probably shouldn't reuse providers for multiple purposes. Instead, you can create your own custom TOTP provider based on the built-in types, and use that to generate tokens.
Creating a custom TOTP token provider for passwordless login
Creating your own token provider sounds like a scary (and silly) thing to do, but thankfully all of the hard work is already available in the ASP.NET Core Identity libraries. All you need to do is derive from the abstract TotpSecurityStampBasedTokenProvider<>
base class, and override a couple of simple methods:
public class PasswordlessLoginTotpTokenProvider<TUser> : TotpSecurityStampBasedTokenProvider<TUser>
where TUser : class
{
public override Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<TUser> manager, TUser user)
{
return Task.FromResult(false);
}
public override async Task<string> GetUserModifierAsync(string purpose, UserManager<TUser> manager, TUser user)
{
var email = await manager.GetEmailAsync(user);
return "PasswordlessLogin:" + purpose + ":" + email;
}
}
I've set CanGenerateTwoFactorTokenAsync()
to always return false
, so that the ASP.NET Core Identity system doesn't try to use the PasswordlessLoginTotpTokenProvider
to generate 2FA codes. Unlike the SMS or Authenticator providers, we only want to use this provider for generating tokens as part of our passwordless login workflow.
The GetUserModifierAsync()
method should return a string consisting of
... a constant, provider and user unique modifier used for entropy in generated tokens from user information.
I've used the user's email as the modifier in this case, but you could also use their ID for example.
You still need to register the provider with ASP.NET Core Identity. In traditional ASP.NET Core fashion, we can create an extension method to do this (mirroring the approach taken in the framework libraries):
public static class CustomIdentityBuilderExtensions
{
public static IdentityBuilder AddPasswordlessLoginTotpTokenProvider(this IdentityBuilder builder)
{
var userType = builder.UserType;
var totpProvider = typeof(PasswordlessLoginTotpTokenProvider<>).MakeGenericType(userType);
return builder.AddTokenProvider("PasswordlessLoginTotpProvider", totpProvider);
}
}
and then we can add our provider as part of the Identity setup in Startup:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<IdentityDbContext>()
.AddDefaultTokenProviders()
.AddPasswordlessLoginTotpTokenProvider(); // Add the custom token provider
}
}
To use the token provider in your workflow, you need to provide the key "PasswordlessLoginTotpProvider"
(that we used when registering the provider) to the UserManager.GenerateUserTokenAsync()
call.
var token = await userManager.GenerateUserTokenAsync(
user, "PasswordlessLoginTotpProvider", "passwordless-auth");
If you compare that line to Scott's post, you'll see that we're passing "PasswordlessLoginTotpProvider"
as the provider name instead of "Default"
.
Similarly, you'll need to pass the new provider key in the call to VerifyUserTokenAsync
:
var isValid = await userManager.VerifyUserTokenAsync(
user, "PasswordlessLoginTotpProvider", "passwordless-auth", token);
If you're following along with Scott's post, you will now be using tokens witth a much shorter lifetime than the 1 day default!
Creating a data-protection based token provider with a different token lifetime
TOTP tokens are good for tokens with very short lifetimes (nominally 30 seconds), but if you want your link to be valid for 15 minutes, then you'll need to use a different provider. The default DataProtectorTokenProvider
uses the ASP.NET Core Data Protection system to generate tokens, so they can be much more long lived.
If you want to use the DataProtectorTokenProvider
for your own tokens, and you don't want to change the default token lifetime for all other uses (email confirmation etc), you'll need to create a custom token provider again, this time based on DataProtectorTokenProvider
.
Given that all you're trying to do here is change the passwordless login token lifetime, your implementation can be very simple. First, create a custom Options object, that derives from DataProtectionTokenProviderOptions
, and overrides the default values:
public class PasswordlessLoginTokenProviderOptions : DataProtectionTokenProviderOptions
{
public PasswordlessLoginTokenProviderOptions()
{
// update the defaults
Name = "PasswordlessLoginTokenProvider";
TokenLifespan = TimeSpan.FromMinutes(15);
}
}
Next, create a custom token provider, that derives from DataProtectorTokenProvider
, and takes your new Options object as a parameter:
public class PasswordlessLoginTokenProvider<TUser> : DataProtectorTokenProvider<TUser>
where TUser: class
{
public PasswordlessLoginTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions<PasswordlessLoginTokenProviderOptions> options)
: base(dataProtectionProvider, options)
{
}
}
As you can see, this class is very simple! Its token generating code is completely encapsulated in the base DataProtectorTokenProvider<>
; all you're doing is ensuring the PasswordlessLoginTokenProviderOptions
token lifetime is used instead of the default.
You can again create an extension method to make it easier to register the provider with ASP.NET Core Identity:
public static class CustomIdentityBuilderExtensions
{
public static IdentityBuilder AddPasswordlessLoginTokenProvider(this IdentityBuilder builder)
{
var userType = builder.UserType;
var provider= typeof(PasswordlessLoginTokenProvider<>).MakeGenericType(userType);
return builder.AddTokenProvider("PasswordlessLoginProvider", provider);
}
}
and add it to the IdentityBuilder instance:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<IdentityDbContext>()
.AddDefaultTokenProviders()
.AddPasswordlessLoginTokenProvider(); // Add the token provider
}
}
Again, be sure you update the GenerateUserTokenAsync
and VerifyUserTokenAsync
calls in your authentication workflow to use the correct provider name ("PasswordlessLoginProvider"
in this case). This will give you almost exactly the same tokens as in Scott's original example, but with the TokenLifespan
reduced to 15 minutes.
Summary
You can implement passwordless authentication in ASP.NET Core Identity using the approach described in Scott Brady's post, but this will result in tokens and magic-links that are valid for a long time period: 1 day by default. In this post I showed three different ways you can reduce the token lifetime: you can change the default lifetime for all tokens; use very short-lived tokens by creating a TOTP provider; or use the ASP.NET Core Data Protection system to create medium-length lifetime tokens.