Implementing custom token providers for passwordless authentication in ASP.NET Core Identity

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:

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.

Bir cevap yazın

E-posta hesabınız yayımlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir

This site uses Akismet to reduce spam. Learn how your comment data is processed.

TOP