ASP.NET Core 3.1 - Token Service

Ken Haggerty
Created 02/20/2021 - Updated 02/23/2021 23:38

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

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.

Comment Count = 0

Please log in to comment.

Login Register
Logged in users receive web notifications.
Web Notifications