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..adae899c3bc7 100644 --- a/src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs +++ b/src/Identity/Extensions.Core/src/IUserAuthenticatorKeyStore.cs @@ -24,8 +24,27 @@ 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/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)