From 70fef2d9e7886c093fe8c382a5e172d3a90f46d4 Mon Sep 17 00:00:00 2001 From: Andrew Korshunov Date: Fri, 6 Sep 2024 11:55:31 -0500 Subject: [PATCH 1/2] #14423 User can verify multiple times with the same two factor token --- .../src/AuthenticatorTokenProvider.cs | 11 ++++ .../src/IUserAuthenticatorKeyStore.cs | 22 ++++++- .../Extensions.Core/src/UserManager.cs | 28 +++++++++ .../src/PublicAPI.Shipped.txt | 2 + .../Extensions.Stores/src/UserStoreBase.cs | 57 ++++++++++++++++++- .../test/InMemory.Test/InMemoryUserStore.cs | 37 +++++++++++- 6 files changed, 153 insertions(+), 4 deletions(-) diff --git a/src/Identity/Extensions.Core/src/AuthenticatorTokenProvider.cs b/src/Identity/Extensions.Core/src/AuthenticatorTokenProvider.cs index 2d62fac47284..6a13b489a3db 100644 --- a/src/Identity/Extensions.Core/src/AuthenticatorTokenProvider.cs +++ b/src/Identity/Extensions.Core/src/AuthenticatorTokenProvider.cs @@ -64,6 +64,17 @@ public virtual async Task ValidateAsync(string purpose, string token, User #endif var timestep = Convert.ToInt64(unixTimestamp / 30); + + var storedOtpTimestamp = await manager.GetAuthenticatorTimestampAsync(user).ConfigureAwait(false); + + // Do not allow re-use of TOTP codes (Section 5.2 of RFC 6238). + if (storedOtpTimestamp.HasValue && storedOtpTimestamp >= timestep) + { + return false; + } + + await manager.SetAuthenticatorTimestampAsync(user, timestep).ConfigureAwait(false); + // Allow codes from 90s in each direction (we could make this configurable?) for (int i = -2; i <= 2; i++) { diff --git a/src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs b/src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs index bf3ba590ea1f..222ec569d77b 100644 --- a/src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs +++ b/src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs @@ -24,8 +24,28 @@ public interface IUserAuthenticatorKeyStore : IUserStore where TUs /// /// Get the authenticator key for the specified . /// - /// The user whose security stamp should be set. + /// The user whose security stamp should be retrieved. /// The used to propagate notifications that the operation should be canceled. /// The that represents the asynchronous operation, containing the security stamp for the specified . Task GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken); + + /// + /// Get the last verified authenticator timestamp for the specified . + /// + /// The user whose authenticator timestamp should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the last verified authenticator timestamp for the specified , + /// or null, when the authenticator code has never been verified. + Task GetAuthenticatorTimestampAsync(TUser user, CancellationToken cancellationToken); + + /// + /// Set the last verified authenticator timestamp for the specified . + /// + /// The user whose authenticator timestamp should be set. + /// The new timestamp value to be set. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the security stamp for the specified . + Task SetAuthenticatorTimestampAsync(TUser user, long timestamp, CancellationToken cancellationToken); } diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index cca2005d10d0..84af0d442ec0 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -1983,6 +1983,34 @@ public virtual async Task ResetAuthenticatorKeyAsync(TUser user) return await UpdateAsync(user).ConfigureAwait(false); } + /// + /// Returns the authenticator last verified timestamp for the user. + /// + /// The user. + /// The authenticator key + public virtual Task GetAuthenticatorTimestampAsync(TUser user) + { + ThrowIfDisposed(); + var store = GetAuthenticatorKeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + return store.GetAuthenticatorTimestampAsync(user, CancellationToken); + } + + /// + /// Updates the authenticator last verified timestamp for the user. + /// + /// The user. + /// The timestamp to save. + /// Whether the user was successfully updated. + public virtual async Task SetAuthenticatorTimestampAsync(TUser user, long timestamp) + { + ThrowIfDisposed(); + var store = GetAuthenticatorKeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); + await store.SetAuthenticatorTimestampAsync(user, timestamp, CancellationToken).ConfigureAwait(false); + return await UpdateAsync(user).ConfigureAwait(false); + } + /// /// Generates a new base32 encoded 160-bit security secret (size of SHA1 hash). /// diff --git a/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt b/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt index dfc33a990254..bc7114bf8c0e 100644 --- a/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt +++ b/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt @@ -169,6 +169,7 @@ virtual Microsoft.AspNetCore.Identity.UserStoreBase.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetAccessFailedCountAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetAuthenticatorKeyAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetAuthenticatorTimestampAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetEmailAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetEmailConfirmedAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetLockoutEnabledAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -190,6 +191,7 @@ virtual Microsoft.AspNetCore.Identity.UserStoreBase.ReplaceCodesAsync(TUser! user, System.Collections.Generic.IEnumerable! recoveryCodes, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.ResetAccessFailedCountAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.SetAuthenticatorKeyAsync(TUser! user, string! key, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Identity.UserStoreBase.SetAuthenticatorTimestampAsync(TUser! user, long! timestamp, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.SetEmailAsync(TUser! user, string? email, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.SetEmailConfirmedAsync(TUser! user, bool confirmed, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.SetLockoutEnabledAsync(TUser! user, bool enabled, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Identity/Extensions.Stores/src/UserStoreBase.cs b/src/Identity/Extensions.Stores/src/UserStoreBase.cs index c45dd197e4a2..6c8edc7547e5 100644 --- a/src/Identity/Extensions.Stores/src/UserStoreBase.cs +++ b/src/Identity/Extensions.Stores/src/UserStoreBase.cs @@ -885,6 +885,7 @@ public virtual async Task RemoveTokenAsync(TUser user, string loginProvider, str private const string InternalLoginProvider = "[AspNetUserStore]"; private const string AuthenticatorKeyTokenName = "AuthenticatorKey"; private const string RecoveryCodeTokenName = "RecoveryCodes"; + private const char AuthenticatorKeyTimestampSeparator = ';'; /// /// Sets the authenticator key for the specified . @@ -902,8 +903,60 @@ public virtual Task SetAuthenticatorKeyAsync(TUser user, string key, Cancellatio /// The user whose security stamp should be set. /// The used to propagate notifications that the operation should be canceled. /// The that represents the asynchronous operation, containing the security stamp for the specified . - public virtual Task GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken) - => GetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, cancellationToken); + public virtual async Task GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken) + { + var token = await GetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, cancellationToken).ConfigureAwait(false); + return token?.Split(AuthenticatorKeyTimestampSeparator).First(); + } + + /// + /// Get the last verified authenticator timestamp for the specified . + /// + /// The user whose authenticator timestamp should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the last verified authenticator timestamp for the specified , + /// or null, when the authenticator code has never been verified. + public async Task GetAuthenticatorTimestampAsync(TUser user, CancellationToken cancellationToken) + { + var token = await GetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, cancellationToken).ConfigureAwait(false); + var keyParts = token?.Split(AuthenticatorKeyTimestampSeparator); + + if (keyParts?.Length == 2) + { + if (long.TryParse(keyParts[1], out var timestamp)) + { + return timestamp; + } + + throw new InvalidOperationException("Invalid authenticator key and timestamp pair format"); + } + + return null; + } + + /// + /// Set the last verified authenticator timestamp for the specified . + /// + /// The user whose authenticator timestamp should be set. + /// The new timestamp value to be set. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the security stamp for the specified . + public async Task SetAuthenticatorTimestampAsync(TUser user, long timestamp, CancellationToken cancellationToken) + { + var key = await GetAuthenticatorKeyAsync(user, cancellationToken).ConfigureAwait(false); + + if (key != null) + { + var keyTimestampPair = $"{key}{AuthenticatorKeyTimestampSeparator}{timestamp}"; + await base.SetAuthenticatorKeyAsync(user, keyTimestampPair, cancellationToken); + } + else + { + throw new InvalidOperationException("Unable to store authenticator timestamp - no authenticator key exists"); + } + } /// /// Returns how many recovery code are still valid for a user. diff --git a/src/Identity/test/InMemory.Test/InMemoryUserStore.cs b/src/Identity/test/InMemory.Test/InMemoryUserStore.cs index 807e82e3f993..7e14bc67de16 100644 --- a/src/Identity/test/InMemory.Test/InMemoryUserStore.cs +++ b/src/Identity/test/InMemory.Test/InMemoryUserStore.cs @@ -393,6 +393,7 @@ public Task GetTokenAsync(TUser user, string loginProvider, string name, private const string AuthenticatorStoreLoginProvider = "[AspNetAuthenticatorStore]"; private const string AuthenticatorKeyTokenName = "AuthenticatorKey"; private const string RecoveryCodeTokenName = "RecoveryCodes"; + private const char AuthenticatorKeyTimestampSeparator = ';'; public Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken) { @@ -401,7 +402,41 @@ public Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken c public Task GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken) { - return GetTokenAsync(user, AuthenticatorStoreLoginProvider, AuthenticatorKeyTokenName, cancellationToken); + var token = await GetTokenAsync(user, AuthenticatorStoreLoginProvider, AuthenticatorKeyTokenName, cancellationToken).ConfigureAwait(false); + return token?.Split(AuthenticatorKeyTimestampSeparator).First(); + } + + public async Task GetAuthenticatorTimestampAsync(TUser user, CancellationToken cancellationToken) + { + var token = await GetTokenAsync(user, AuthenticatorStoreLoginProvider, AuthenticatorKeyTokenName, cancellationToken).ConfigureAwait(false); + var keyParts = token?.Split(AuthenticatorKeyTimestampSeparator); + + if (keyParts?.Length == 2) + { + if (long.TryParse(keyParts[1], out var timestamp)) + { + return timestamp; + } + + throw new InvalidOperationException("Invalid authenticator key and timestamp pair format"); + } + + return null; + } + + public async Task SetAuthenticatorTimestampAsync(TUser user, long timestamp, CancellationToken cancellationToken) + { + var key = await GetAuthenticatorKeyAsync(user, cancellationToken).ConfigureAwait(false); + + if (key != null) + { + var keyTimestampPair = $"{key}{AuthenticatorKeyTimestampSeparator}{timestamp}"; + await base.SetAuthenticatorKeyAsync(user, keyTimestampPair, cancellationToken); + } + else + { + throw new InvalidOperationException("Unable to store authenticator timestamp - no authenticator key exists"); + } } public Task ReplaceCodesAsync(TUser user, IEnumerable recoveryCodes, CancellationToken cancellationToken) From 1da360aaf59d8b481124688e55689d81e5af375c Mon Sep 17 00:00:00 2001 From: Andrew Korshunov Date: Fri, 6 Sep 2024 14:57:49 -0500 Subject: [PATCH 2/2] #14423 Fixing build issues --- src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs | 1 - src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs b/src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs index 222ec569d77b..adae899c3bc7 100644 --- a/src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs +++ b/src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs @@ -45,7 +45,6 @@ public interface IUserAuthenticatorKeyStore : IUserStore where TUs /// The user whose authenticator timestamp should be set. /// The new timestamp value to be set. /// The used to propagate notifications that the operation should be canceled. - /// /// The that represents the asynchronous operation, containing the security stamp for the specified . Task SetAuthenticatorTimestampAsync(TUser user, long timestamp, CancellationToken cancellationToken); } diff --git a/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt b/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt index bc7114bf8c0e..dfc33a990254 100644 --- a/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt +++ b/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt @@ -169,7 +169,6 @@ virtual Microsoft.AspNetCore.Identity.UserStoreBase.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetAccessFailedCountAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetAuthenticatorKeyAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetAuthenticatorTimestampAsync(TUser! user, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetEmailAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetEmailConfirmedAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.GetLockoutEnabledAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -191,7 +190,6 @@ virtual Microsoft.AspNetCore.Identity.UserStoreBase.ReplaceCodesAsync(TUser! user, System.Collections.Generic.IEnumerable! recoveryCodes, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.ResetAccessFailedCountAsync(TUser! user, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.SetAuthenticatorKeyAsync(TUser! user, string! key, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -virtual Microsoft.AspNetCore.Identity.UserStoreBase.SetAuthenticatorTimestampAsync(TUser! user, long! timestamp, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.SetEmailAsync(TUser! user, string? email, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.SetEmailConfirmedAsync(TUser! user, bool confirmed, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Microsoft.AspNetCore.Identity.UserStoreBase.SetLockoutEnabledAsync(TUser! user, bool enabled, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!