ASP.NET Core 3.1 - Token Service
This article will describe the implementation of functions to generate and validate tokens used to verify email addresses and reset passwords. I will assume you have downloaded the ASP.NET Core 3.1 - Users Without Identity Project or created a new ASP.NET Core 3.1 Razor Pages project. See Tutorial: Get started with Razor Pages in ASP.NET Core. You should review the earlier articles of the Users Without Identity Project series.
Users Without Identity Project and Article Series
Creation Series
- ASP.NET Core 3.1 - Users Without Identity
- ASP.NET Core 3.1 - User Entity
- ASP.NET Core 3.1 - Password Hasher
- ASP.NET Core 3.1 - User Management
- ASP.NET Core 3.1 - Admin Role
- ASP.NET Core 3.1 - Cookie Validator
- ASP.NET Core 3.1 - Concurrency Conflicts
- ASP.NET Core 3.1 - Must Change Password
- ASP.NET Core 3.1 - User Database Service
- ASP.NET Core 3.1 - Rename Related Entities
2FA Series
- ASP.NET Core 3.1 - 2FA Without Identity
- ASP.NET Core 3.1 - 2FA User Tokens
- ASP.NET Core 3.1 - 2FA Cookie Schemes
- ASP.NET Core 3.1 - 2FA Authenticating
- ASP.NET Core 3.1 - 2FA Sign In Service
- ASP.NET Core 3.1 - 2FA QR Code Generator
- ASP.NET Core 3.1 - Admin 2FA Requirement
- ASP.NET Core 3.1 - 2FA Admin Override
Enhanced User Series
- ASP.NET Core 3.1 - Enhanced User Without Identity
- ASP.NET Core 3.1 - 2FA Recovery Codes
- ASP.NET Core 3.1 - Login Lockout
- ASP.NET Core 3.1 - Created And Last Login Date
- ASP.NET Core 3.1 - Security Stamp
- ASP.NET Core 3.1 - Token Service
- ASP.NET Core 3.1 - Confirmed Email Address
- ASP.NET Core 3.1 - Password Recovery
UWIP v2 implements a TokenService class to generate and validate email confirmation and password reset tokens based on the SecurityStamp. The TokenService employs a ServiceCollection extension and options to override the default values for the length of time the token is valid.
Services > Utilities > TokenService.cs:
// Copyright © Ken Haggerty (https://kenhaggerty.com)
// Licensed under the MIT License.
public interface ITokenService
{
/// <summary>
/// Generates a new security stamp.
/// </summary>
/// <returns><see cref="string" /></returns>
string GenerateSecurityStamp();
/// <summary>
/// Generates a new Base64Url encoded email confirmation token for the <paramref name="appUser" />.
/// </summary>
/// <param name="appUser"></param>
/// <returns><see cref="Task{TResult}" /> for <see cref="string" /></returns>
/// <remarks>
/// Serializes the <see cref="AppUser.Id" />, <see cref="AppUser.SecurityStamp" />,
/// <see cref="TokenService.EmailConfirmationPurpose" />, and an expiration date time based on
/// <see cref="TokenServiceOptions.EmailConfirmationLifeTimeMinutes" />.
/// </remarks>
Task<string> GenerateEmailConfirmationTokenAsync(AppUser appUser);
/// <summary>
/// Decodes and verifies the <paramref name="token" /> for the <paramref name="appUser" />
/// </summary>
/// <param name="appUser"></param>
/// <param name="token"></param>
/// <returns><see cref="Task{TResult}" /> for <see cref="bool" /></returns>
/// <remarks>
/// Deserializes the provided token and validates the <see cref="AppUser.Id" />,
/// <see cref="AppUser.SecurityStamp" />, <see cref="TokenService.EmailConfirmationPurpose" />,
/// and expiration date.
/// </remarks>
Task<bool> ValidateEmailConfirmationTokenAsync(AppUser appUser, string token);
/// <summary>
/// Generates a new Base64Url encoded password reset token for the <paramref name="appUser" />.
/// </summary>
/// <param name="appUser"></param>
/// <returns><see cref="Task{TResult}" /> for <see cref="string" /></returns>
/// <remarks>
/// Serializes the <see cref="AppUser.Id" />, <see cref="AppUser.SecurityStamp" />,
/// <see cref="TokenService.PasswordResetPurpose" />, and an expiration date time based on
/// <see cref="TokenServiceOptions.PasswordResetLifeTimeMinutes" />.
/// </remarks>
Task<string> GeneratePasswordResetTokenAsync(AppUser appUser);
/// <summary>
/// Decodes and verifies the <paramref name="token" /> for the <paramref name="appUser" />
/// </summary>
/// <param name="appUser"></param>
/// <param name="token"></param>
/// <returns><see cref="Task{TResult}" /> for <see cref="bool" /></returns>
/// <remarks>
/// Deserializes the provided token and validates the <see cref="AppUser.Id" />,
/// <see cref="AppUser.SecurityStamp" />, <see cref="TokenService.PasswordResetPurpose" />,
/// and expiration date.
/// </remarks>
Task<bool> ValidatePasswordResetTokenAsync(AppUser appUser, string token);
}
public class TokenService : ITokenService
{
private readonly string EmailConfirmationPurpose = "EmailConfirmation";
private readonly string PasswordResetPurpose = "PasswordReset";
private readonly int _emailConfirmationLifeTimeMinutes;
private readonly int _passwordResetLifeTimeMinutes;
public TokenService(IOptions<TokenServiceOptions> options)
{
_emailConfirmationLifeTimeMinutes = options.Value.EmailConfirmationLifeTimeMinutes;
_passwordResetLifeTimeMinutes = options.Value.PasswordResetLifeTimeMinutes;
}
public string GenerateSecurityStamp()
{
byte[] bytes = new byte[20];
RandomNumberGenerator.Fill(bytes);
return Base32.ToBase32(bytes);
}
public async Task<string> GenerateEmailConfirmationTokenAsync(AppUser appUser)
{
var token = await GenerateTokenAsync(appUser, EmailConfirmationPurpose, EmailConfirmationLifeTimeMinutes)
.ConfigureAwait(false);
return WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
}
public async Task<bool> ValidateEmailConfirmationTokenAsync(AppUser appUser, string token)
{
token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token));
return await ValidateTokenAsync(appUser, EmailConfirmationPurpose, token).ConfigureAwait(false);
}
public async Task<string> GeneratePasswordResetTokenAsync(AppUser appUser)
{
var token = await GenerateTokenAsync(appUser, PasswordResetPurpose, PasswordResetLifeTimeMinutes)
.ConfigureAwait(false);
return WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
}
public async Task<bool> ValidatePasswordResetTokenAsync(AppUser appUser, string token)
{
token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token));
return await ValidateTokenAsync(appUser, PasswordResetPurpose, token).ConfigureAwait(false);
}
private static async Task<string> GenerateTokenAsync(AppUser appUser, string purpose, int lifeTimeMinutes)
{
if (appUser == null) throw new ArgumentNullException(nameof(appUser));
var expirationDate = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(lifeTimeMinutes);
using MemoryStream memoryStream = new MemoryStream();
using (StreamWriter streamWriter = new StreamWriter(memoryStream))
{
await streamWriter.WriteLineAsync(expirationDate.ToString()).ConfigureAwait(false);
await streamWriter.WriteLineAsync(appUser.Id.ToString()).ConfigureAwait(false);
await streamWriter.WriteLineAsync(appUser.SecurityStamp).ConfigureAwait(false);
await streamWriter.WriteLineAsync(purpose).ConfigureAwait(false);
}
return Convert.ToBase64String(memoryStream.ToArray());
}
private static async Task<bool> ValidateTokenAsync(AppUser appUser, string purpose, string token)
{
try
{
using MemoryStream memoryStream = new MemoryStream(Convert.FromBase64String(token));
using StreamReader streamReader = new StreamReader(memoryStream);
var expirationDateString = await streamReader.ReadLineAsync().ConfigureAwait(false);
var expirationDate = DateTimeOffset.Parse(expirationDateString);
if (expirationDate < DateTimeOffset.UtcNow) return false;
var readerAppUserId = await streamReader.ReadLineAsync().ConfigureAwait(false);
var compareAppUserId = int.Parse(readerAppUserId);
if (compareAppUserId != appUser.Id) return false;
var securityStamp = await streamReader.ReadLineAsync().ConfigureAwait(false);
if (!string.Equals(securityStamp, appUser.SecurityStamp)) return false;
var readerPurpose = await streamReader.ReadLineAsync().ConfigureAwait(false);
if (!string.Equals(readerPurpose, purpose)) return false;
if (!streamReader.EndOfStream)return false;
return true;
}
catch
{
// Do not leak exception
}
return false;
}
}
Entities > TokenServiceOptions.cs:
// Copyright © Ken Haggerty (https://kenhaggerty.com)
// Licensed under the MIT License.
public class TokenServiceOptions
{
/// <summary>
/// The number of minutes a new email confirmation token is valid.
/// </summary>
public int EmailConfirmationLifeTimeMinutes { get; set; } = 240;
/// <summary>
/// The number of minutes a new password reset token is valid.
/// </summary>
public int PasswordResetLifeTimeMinutes { get; set; } = 60;
}
Services > Extensions > TokenServiceExtension.cs:
// Copyright © Ken Haggerty (https://kenhaggerty.com)
// Licensed under the MIT License.
public static class TokenServiceExtension
{
/// <summary>
/// Adds a singleton service for email confirmation and password reset tokens to the specified <see cref="IServiceCollection" />.
/// </summary>
/// <param name="serviceCollection"><see cref="IServiceCollection" /></param>
/// <param name="options"><see cref="TokenServiceOptions" /></param>
/// <returns>
/// The <see cref="IServiceCollection" /> so that additional calls can be chained.
/// </returns>
/// <remarks>
/// TokenServiceOptions defaults: EmailConfirmationLifeTimeMinutes = 240, PasswordResetLifeTimeMinutes = 60
/// </remarks>
public static IServiceCollection AddTokenService(this IServiceCollection serviceCollection,
Action<TokenServiceOptions> options = null)
{
serviceCollection.AddSingleton<ITokenService, TokenService>();
if(options != null)
serviceCollection.Configure(options);
return serviceCollection;
}
}
The TokenServiceExtension is employed in Startup. ConfigureServices.
Startup > ConfigureServices:
services.AddTokenService(options =>
{
options.EmailConfirmationLifeTimeMinutes = 120;
});
Update 02/23/2021
I updated the article links.
References:
- ASP.NET CORE IDENTITY TOKEN PROVIDERS – UNDER THE HOOD
- GitHub - dotnet/aspnetcore/src/Identity/Core/src/DataProtectorTokenProvider.cs
- codeburst.io - Options Pattern in .NET Core
- MS Docs - Options pattern in ASP.NET Core
- GitHub - Account Confirmation and Password Recovery - change default token timeout
Comments(0)