Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Identity/Core/src/DataProtectionTokenProviderOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,9 @@ public class DataProtectionTokenProviderOptions
/// The amount of time a generated token remains valid.
/// </value>
public TimeSpan TokenLifespan { get; set; } = TimeSpan.FromDays(1);

/// <summary>
/// Gives control over the timestamps for testing purposes.
/// </summary>
public TimeProvider? TimeProvider { get; set; }
}
10 changes: 8 additions & 2 deletions src/Identity/Core/src/DataProtectorTokenProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public DataProtectorTokenProvider(IDataProtectionProvider dataProtectionProvider
// Use the Name as the purpose which should usually be distinct from others
Protector = dataProtectionProvider.CreateProtector(Name ?? "DataProtectorTokenProvider");
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
TimeProvider = Options.TimeProvider ?? TimeProvider.System;
}

/// <summary>
Expand Down Expand Up @@ -66,6 +67,11 @@ public DataProtectorTokenProvider(IDataProtectionProvider dataProtectionProvider
/// </value>
public ILogger<DataProtectorTokenProvider<TUser>> Logger { get; }

/// <summary>
/// Gets the <see cref="System.TimeProvider"/>.
/// </summary>
public TimeProvider TimeProvider { get; }

/// <summary>
/// Generates a protected token for the specified <paramref name="user"/> as an asynchronous operation.
/// </summary>
Expand All @@ -80,7 +86,7 @@ public virtual async Task<string> GenerateAsync(string purpose, UserManager<TUse
var userId = await manager.GetUserIdAsync(user);
using (var writer = ms.CreateWriter())
{
writer.Write(DateTimeOffset.UtcNow);
writer.Write(TimeProvider.GetUtcNow());
writer.Write(userId);
writer.Write(purpose ?? "");
string? stamp = null;
Expand Down Expand Up @@ -115,7 +121,7 @@ public virtual async Task<bool> ValidateAsync(string purpose, string token, User
{
var creationTime = reader.ReadDateTimeOffset();
var expirationTime = creationTime + Options.TokenLifespan;
if (expirationTime < DateTimeOffset.UtcNow)
if (expirationTime < TimeProvider.GetUtcNow())
{
Logger.InvalidExpirationTime();
return false;
Expand Down
17 changes: 17 additions & 0 deletions src/Identity/Core/src/IdentityBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public static IdentityBuilder AddDefaultTokenProviders(this IdentityBuilder buil
var phoneNumberProviderType = typeof(PhoneNumberTokenProvider<>).MakeGenericType(builder.UserType);
var emailTokenProviderType = typeof(EmailTokenProvider<>).MakeGenericType(builder.UserType);
var authenticatorProviderType = typeof(AuthenticatorTokenProvider<>).MakeGenericType(builder.UserType);
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<DataProtectionTokenProviderOptions>, PostConfigureDataProtectionTokenProviderOptions>());
return builder.AddTokenProvider(TokenOptions.DefaultProvider, dataProtectionProviderType)
Comment thread
marcominerva marked this conversation as resolved.
.AddTokenProvider(TokenOptions.DefaultEmailProvider, emailTokenProviderType)
.AddTokenProvider(TokenOptions.DefaultPhoneProvider, phoneNumberProviderType)
Expand Down Expand Up @@ -121,4 +122,20 @@ public void PostConfigure(string? name, SecurityStampValidatorOptions options)
options.TimeProvider ??= TimeProvider;
}
}

// Set TimeProvider from DI on all options instances, if not already set by tests.
private sealed class PostConfigureDataProtectionTokenProviderOptions : IPostConfigureOptions<DataProtectionTokenProviderOptions>
{
public PostConfigureDataProtectionTokenProviderOptions(TimeProvider? timeProvider = null)
{
TimeProvider = timeProvider;
}

private TimeProvider? TimeProvider { get; }

public void PostConfigure(string? name, DataProtectionTokenProviderOptions options)
{
options.TimeProvider ??= TimeProvider;
}
}
}
16 changes: 16 additions & 0 deletions src/Identity/Core/src/IdentityServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ public static class IdentityServiceCollectionExtensions
services.TryAddScoped<IdentityErrorDescriber>();
services.TryAddScoped<ISecurityStampValidator, SecurityStampValidator<TUser>>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<SecurityStampValidatorOptions>, PostConfigureSecurityStampValidatorOptions>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<DataProtectionTokenProviderOptions>, PostConfigureDataProtectionTokenProviderOptions>());
Comment thread
marcominerva marked this conversation as resolved.
services.TryAddScoped<ITwoFactorSecurityStampValidator, TwoFactorSecurityStampValidator<TUser>>();
services.TryAddScoped<IUserClaimsPrincipalFactory<TUser>, UserClaimsPrincipalFactory<TUser, TRole>>();
services.TryAddScoped<IUserConfirmation<TUser>, DefaultUserConfirmation<TUser>>();
Expand Down Expand Up @@ -188,6 +189,21 @@ public void PostConfigure(string? name, SecurityStampValidatorOptions options)
}
}

private sealed class PostConfigureDataProtectionTokenProviderOptions : IPostConfigureOptions<DataProtectionTokenProviderOptions>
{
public PostConfigureDataProtectionTokenProviderOptions(TimeProvider? timeProvider = null)
{
TimeProvider = timeProvider;
}

private TimeProvider? TimeProvider { get; }

public void PostConfigure(string? name, DataProtectionTokenProviderOptions options)
{
options.TimeProvider ??= TimeProvider;
}
}

private sealed class CompositeIdentityHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
: SignInAuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
Expand Down
3 changes: 3 additions & 0 deletions src/Identity/Core/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
#nullable enable
Microsoft.AspNetCore.Identity.DataProtectionTokenProviderOptions.TimeProvider.get -> System.TimeProvider?
Microsoft.AspNetCore.Identity.DataProtectionTokenProviderOptions.TimeProvider.set -> void
Microsoft.AspNetCore.Identity.DataProtectorTokenProvider<TUser>.TimeProvider.get -> System.TimeProvider!
163 changes: 163 additions & 0 deletions src/Identity/test/Identity.Test/DataProtectorTokenProviderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;

namespace Microsoft.AspNetCore.Identity.Test;

public class DataProtectorTokenProviderTest
{
private static DataProtectorTokenProvider<PocoUser> CreateProvider(DataProtectionTokenProviderOptions options = null)
{
var dataProtectionProvider = new EphemeralDataProtectionProvider(new LoggerFactory());
var optionsAccessor = new Mock<IOptions<DataProtectionTokenProviderOptions>>();
optionsAccessor.Setup(o => o.Value).Returns(options ?? new DataProtectionTokenProviderOptions());
var logger = new Mock<ILogger<DataProtectorTokenProvider<PocoUser>>>().Object;
return new DataProtectorTokenProvider<PocoUser>(dataProtectionProvider, optionsAccessor.Object, logger);
}

private static UserManager<PocoUser> CreateUserManager()
=> MockHelpers.TestUserManager(new NoopUserStore());

[Fact]
public async Task GenerateAndValidateTokenSucceeds()
{
var provider = CreateProvider();
var manager = CreateUserManager();
var user = new PocoUser("testuser");

var token = await provider.GenerateAsync("purpose", manager, user);
var valid = await provider.ValidateAsync("purpose", token, manager, user);

Assert.True(valid);
}

[Fact]
public async Task ValidateFailsForDifferentPurpose()
{
var provider = CreateProvider();
var manager = CreateUserManager();
var user = new PocoUser("testuser");

var token = await provider.GenerateAsync("purpose1", manager, user);
var valid = await provider.ValidateAsync("purpose2", token, manager, user);

Assert.False(valid);
}

[Fact]
public async Task ValidateFailsForDifferentUser()
{
var provider = CreateProvider();
var manager = CreateUserManager();
var user1 = new PocoUser("user1");
var user2 = new PocoUser("user2");

var token = await provider.GenerateAsync("purpose", manager, user1);
var valid = await provider.ValidateAsync("purpose", token, manager, user2);

Assert.False(valid);
}

[Fact]
public async Task ValidateFailsAfterTokenLifespanExpires()
{
var timeProvider = new FakeTimeProvider();
var options = new DataProtectionTokenProviderOptions
{
TokenLifespan = TimeSpan.FromHours(1),
TimeProvider = timeProvider,
};
var provider = CreateProvider(options);
var manager = CreateUserManager();
var user = new PocoUser("testuser");

var token = await provider.GenerateAsync("purpose", manager, user);

timeProvider.Advance(TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1));

var valid = await provider.ValidateAsync("purpose", token, manager, user);

Assert.False(valid);
}

[Fact]
public async Task ValidateSucceedsWithinTokenLifespan()
{
var timeProvider = new FakeTimeProvider();
var options = new DataProtectionTokenProviderOptions
{
TokenLifespan = TimeSpan.FromHours(1),
TimeProvider = timeProvider,
};
var provider = CreateProvider(options);
var manager = CreateUserManager();
var user = new PocoUser("testuser");

var token = await provider.GenerateAsync("purpose", manager, user);

timeProvider.Advance(TimeSpan.FromMinutes(59));

var valid = await provider.ValidateAsync("purpose", token, manager, user);

Assert.True(valid);
}

[Fact]
public async Task TimeProviderFromDIIsInjectedViaPostConfigure()
{
var timeProvider = new FakeTimeProvider();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider(new LoggerFactory()));
services.AddSingleton<TimeProvider>(timeProvider);
services.AddIdentityCore<PocoUser>()
.AddDefaultTokenProviders()
.AddUserStore<NoopUserStore>();
Comment thread
marcominerva marked this conversation as resolved.

var sp = services.BuildServiceProvider();
var manager = sp.GetRequiredService<UserManager<PocoUser>>();
var user = new PocoUser("testuser");

var token = await manager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "purpose");

timeProvider.Advance(TimeSpan.FromDays(1) + TimeSpan.FromSeconds(1));

var valid = await manager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "purpose", token);

Assert.False(valid);
}

[Fact]
public async Task TimeProviderFromDIDoesNotOverrideManuallySetTimeProvider()
{
var diTimeProvider = new FakeTimeProvider();
var optionsTimeProvider = new FakeTimeProvider();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider(new LoggerFactory()));
services.AddSingleton<TimeProvider>(diTimeProvider);
services.AddIdentityCore<PocoUser>()
.AddDefaultTokenProviders()
.AddUserStore<NoopUserStore>();
services.Configure<DataProtectionTokenProviderOptions>(o => o.TimeProvider = optionsTimeProvider);

var sp = services.BuildServiceProvider();
var manager = sp.GetRequiredService<UserManager<PocoUser>>();
var user = new PocoUser("testuser");

var token = await manager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "purpose");

// Advance only the options time provider — should cause expiry
optionsTimeProvider.Advance(TimeSpan.FromDays(1) + TimeSpan.FromSeconds(1));

var valid = await manager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "purpose", token);

Assert.False(valid);
}
}
Loading