diff --git a/src/Microsoft.AspNetCore.Identity.Specification.Tests/UserManagerSpecificationTests.cs b/src/Microsoft.AspNetCore.Identity.Specification.Tests/UserManagerSpecificationTests.cs
index 0cc5f8e3b0..df5dae884c 100644
--- a/src/Microsoft.AspNetCore.Identity.Specification.Tests/UserManagerSpecificationTests.cs
+++ b/src/Microsoft.AspNetCore.Identity.Specification.Tests/UserManagerSpecificationTests.cs
@@ -7,7 +7,6 @@ using System.Linq;
using System.Linq.Expressions;
using System.Security.Claims;
using System.Threading.Tasks;
-using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -1890,6 +1889,7 @@ namespace Microsoft.AspNetCore.Identity.Test
{
IdentityResultAssert.IsSuccess(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code));
IdentityResultAssert.IsFailure(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code));
+ Assert.Equal(--numCodes, await manager.CountRecoveryCodesAsync(user));
}
// One last time to be sure
foreach (var code in newCodes)
diff --git a/src/Microsoft.Extensions.Identity.Core/IUserTwoFactorRecoveryCodeStore.cs b/src/Microsoft.Extensions.Identity.Core/IUserTwoFactorRecoveryCodeStore.cs
index fdc1544b13..9de8e2e5ce 100644
--- a/src/Microsoft.Extensions.Identity.Core/IUserTwoFactorRecoveryCodeStore.cs
+++ b/src/Microsoft.Extensions.Identity.Core/IUserTwoFactorRecoveryCodeStore.cs
@@ -31,5 +31,13 @@ namespace Microsoft.AspNetCore.Identity
/// The used to propagate notifications that the operation should be canceled.
/// True if the recovery code was found for the user.
Task RedeemCodeAsync(TUser user, string code, CancellationToken cancellationToken);
+
+ ///
+ /// Returns how many recovery code are still valid for a user.
+ ///
+ /// The user who owns the recovery code.
+ /// The used to propagate notifications that the operation should be canceled.
+ /// The number of valid recovery codes for the user..
+ Task CountCodesAsync(TUser user, CancellationToken cancellationToken);
}
}
\ No newline at end of file
diff --git a/src/Microsoft.Extensions.Identity.Core/UserManager.cs b/src/Microsoft.Extensions.Identity.Core/UserManager.cs
index 0a9f212374..8ced663058 100644
--- a/src/Microsoft.Extensions.Identity.Core/UserManager.cs
+++ b/src/Microsoft.Extensions.Identity.Core/UserManager.cs
@@ -2167,7 +2167,7 @@ namespace Microsoft.AspNetCore.Identity
///
/// The user to generate recovery codes for.
/// The number of codes to generate.
- /// The new recovery codes for the user.
+ /// The new recovery codes for the user. Note: there may be less than number returned, as duplicates will be removed.
public virtual async Task> GenerateNewTwoFactorRecoveryCodesAsync(TUser user, int number)
{
ThrowIfDisposed();
@@ -2183,7 +2183,7 @@ namespace Microsoft.AspNetCore.Identity
newCodes.Add(CreateTwoFactorRecoveryCode());
}
- await store.ReplaceCodesAsync(user, newCodes, CancellationToken);
+ await store.ReplaceCodesAsync(user, newCodes.Distinct(), CancellationToken);
var update = await UpdateAsync(user);
if (update.Succeeded)
{
@@ -2197,9 +2197,7 @@ namespace Microsoft.AspNetCore.Identity
///
///
protected virtual string CreateTwoFactorRecoveryCode()
- {
- return Guid.NewGuid().ToString();
- }
+ => Guid.NewGuid().ToString().Substring(0, 8);
///
/// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid
@@ -2225,6 +2223,23 @@ namespace Microsoft.AspNetCore.Identity
return IdentityResult.Failed(ErrorDescriber.RecoveryCodeRedemptionFailed());
}
+ ///
+ /// Returns how many recovery code are still valid for a user.
+ ///
+ /// The user.
+ /// How many recovery code are still valid for a user.
+ public virtual Task CountRecoveryCodesAsync(TUser user)
+ {
+ ThrowIfDisposed();
+ var store = GetRecoveryCodeStore();
+ if (user == null)
+ {
+ throw new ArgumentNullException(nameof(user));
+ }
+
+ return store.CountCodesAsync(user, CancellationToken);
+ }
+
///
/// Releases the unmanaged resources used by the role manager and optionally releases the managed resources.
///
diff --git a/src/Microsoft.Extensions.Identity.Stores/UserStoreBase.cs b/src/Microsoft.Extensions.Identity.Stores/UserStoreBase.cs
index 749cbaf8e6..a8259092ca 100644
--- a/src/Microsoft.Extensions.Identity.Stores/UserStoreBase.cs
+++ b/src/Microsoft.Extensions.Identity.Stores/UserStoreBase.cs
@@ -992,9 +992,7 @@ namespace Microsoft.AspNetCore.Identity
/// The used to propagate notifications that the operation should be canceled.
/// The that represents the asynchronous operation.
public virtual Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken)
- {
- return SetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, key, cancellationToken);
- }
+ => SetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, key, cancellationToken);
///
/// Get the authenticator key for the specified .
@@ -1003,8 +1001,29 @@ namespace Microsoft.AspNetCore.Identity
/// 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);
+
+ ///
+ /// Returns how many recovery code are still valid for a user.
+ ///
+ /// The user who owns the recovery code.
+ /// The used to propagate notifications that the operation should be canceled.
+ /// The number of valid recovery codes for the user..
+ public virtual async Task CountCodesAsync(TUser user, CancellationToken cancellationToken)
{
- return GetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ ThrowIfDisposed();
+
+ if (user == null)
+ {
+ throw new ArgumentNullException(nameof(user));
+ }
+ var mergedCodes = await GetTokenAsync(user, InternalLoginProvider, RecoveryCodeTokenName, cancellationToken) ?? "";
+ if (mergedCodes.Length > 0)
+ {
+ return mergedCodes.Split(';').Length;
+ }
+ return 0;
}
///
diff --git a/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryUserStore.cs b/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryUserStore.cs
index b71b22e785..de804bacf2 100644
--- a/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryUserStore.cs
+++ b/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryUserStore.cs
@@ -431,5 +431,15 @@ namespace Microsoft.AspNetCore.Identity.InMemory
}
return false;
}
+
+ public async Task CountCodesAsync(TUser user, CancellationToken cancellationToken)
+ {
+ var mergedCodes = await GetTokenAsync(user, AuthenticatorStoreLoginProvider, RecoveryCodeTokenName, cancellationToken) ?? "";
+ if (mergedCodes.Length > 0)
+ {
+ return mergedCodes.Split(';').Length;
+ }
+ return 0;
+ }
}
}