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)