Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Extensions.Options;

namespace Microsoft.AspNetCore.Identity;

// Set TimeProvider from DI on all options instances, if not already set by tests.
internal 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;
}
}
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!
189 changes: 189 additions & 0 deletions src/Identity/test/Identity.Test/DataProtectorTokenProviderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// 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);
}

[Fact]
public async Task TimeProviderFromDIIsInjectedViaAddIdentity()
{
var timeProvider = new FakeTimeProvider();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider(new LoggerFactory()));
services.AddSingleton<TimeProvider>(timeProvider);
services.AddIdentity<PocoUser, PocoRole>()
.AddDefaultTokenProviders()
.AddUserStore<NoopUserStore>()
.AddRoleStore<NoopRoleStore>();

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);
}
}
Loading