diff --git a/src/Microsoft.AspNetCore.Identity/IdentityErrorDescriber.cs b/src/Microsoft.AspNetCore.Identity/IdentityErrorDescriber.cs
index c4df7570ed..3d779dbbd1 100644
--- a/src/Microsoft.AspNetCore.Identity/IdentityErrorDescriber.cs
+++ b/src/Microsoft.AspNetCore.Identity/IdentityErrorDescriber.cs
@@ -241,6 +241,20 @@ namespace Microsoft.AspNetCore.Identity
};
}
+ ///
+ /// Returns an indicating a password does not meet the minimum number of unique chars.
+ ///
+ /// The number of different chars that must be used.
+ /// An indicating a password does not meet the minimum number of unique chars.
+ public virtual IdentityError PasswordRequiresUniqueChars(int uniqueChars)
+ {
+ return new IdentityError
+ {
+ Code = nameof(PasswordRequiresUniqueChars),
+ Description = Resources.FormatPasswordRequiresUniqueChars(uniqueChars)
+ };
+ }
+
///
/// Returns an indicating a password entered does not contain a non-alphanumeric character, which is required by the password policy.
///
diff --git a/src/Microsoft.AspNetCore.Identity/PasswordOptions.cs b/src/Microsoft.AspNetCore.Identity/PasswordOptions.cs
index 1fa75c0215..8a7f51266e 100644
--- a/src/Microsoft.AspNetCore.Identity/PasswordOptions.cs
+++ b/src/Microsoft.AspNetCore.Identity/PasswordOptions.cs
@@ -16,6 +16,14 @@ namespace Microsoft.AspNetCore.Identity
///
public int RequiredLength { get; set; } = 6;
+ ///
+ /// Gets or sets the minimum number of unique chars a password must comprised of.
+ ///
+ ///
+ /// This defaults to 1.
+ ///
+ public int RequiredUniqueChars { get; set; } = 1;
+
///
/// Gets or sets a flag indicating if passwords must contain a non-alphanumeric character.
///
diff --git a/src/Microsoft.AspNetCore.Identity/PasswordValidator.cs b/src/Microsoft.AspNetCore.Identity/PasswordValidator.cs
index 836a7461cc..f19ed6e2f7 100644
--- a/src/Microsoft.AspNetCore.Identity/PasswordValidator.cs
+++ b/src/Microsoft.AspNetCore.Identity/PasswordValidator.cs
@@ -68,6 +68,10 @@ namespace Microsoft.AspNetCore.Identity
{
errors.Add(Describer.PasswordRequiresUpper());
}
+ if (options.RequiredUniqueChars >= 1 && password.Distinct().Count() < options.RequiredUniqueChars)
+ {
+ errors.Add(Describer.PasswordRequiresUniqueChars(options.RequiredUniqueChars));
+ }
return
Task.FromResult(errors.Count == 0
? IdentityResult.Success
diff --git a/src/Microsoft.AspNetCore.Identity/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Identity/Properties/Resources.Designer.cs
index 75af2a8a22..6d1fe59d2c 100644
--- a/src/Microsoft.AspNetCore.Identity/Properties/Resources.Designer.cs
+++ b/src/Microsoft.AspNetCore.Identity/Properties/Resources.Designer.cs
@@ -746,6 +746,22 @@ namespace Microsoft.AspNetCore.Identity
return GetString("StoreNotIUserTwoFactorRecoveryCodeStore");
}
+ ///
+ /// Passwords must use at least {0} different characters.
+ ///
+ internal static string PasswordRequiresUniqueChars
+ {
+ get { return GetString("PasswordRequiresUniqueChars"); }
+ }
+
+ ///
+ /// Passwords must use at least {0} different characters.
+ ///
+ internal static string FormatPasswordRequiresUniqueChars(object p0)
+ {
+ return string.Format(CultureInfo.CurrentCulture, GetString("PasswordRequiresUniqueChars"), p0);
+ }
+
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);
diff --git a/src/Microsoft.AspNetCore.Identity/Resources.resx b/src/Microsoft.AspNetCore.Identity/Resources.resx
index d4cbe1419e..39a1443a4b 100644
--- a/src/Microsoft.AspNetCore.Identity/Resources.resx
+++ b/src/Microsoft.AspNetCore.Identity/Resources.resx
@@ -301,4 +301,8 @@
Store does not implement IUserTwoFactorRecoveryCodeStore<User>.
Error when the store does not implement this interface
+
+ Passwords must use at least {0} different characters.
+ Error message for passwords that are based on similar characters
+
\ No newline at end of file
diff --git a/test/Microsoft.AspNetCore.Identity.Test/IdentityOptionsTest.cs b/test/Microsoft.AspNetCore.Identity.Test/IdentityOptionsTest.cs
index 759f7ce8da..3370005dcd 100644
--- a/test/Microsoft.AspNetCore.Identity.Test/IdentityOptionsTest.cs
+++ b/test/Microsoft.AspNetCore.Identity.Test/IdentityOptionsTest.cs
@@ -27,6 +27,7 @@ namespace Microsoft.AspNetCore.Identity.Test
Assert.True(options.Password.RequireNonAlphanumeric);
Assert.True(options.Password.RequireUppercase);
Assert.Equal(6, options.Password.RequiredLength);
+ Assert.Equal(1, options.Password.RequiredUniqueChars);
Assert.Equal("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+", options.User.AllowedUserNameCharacters);
Assert.False(options.User.RequireUniqueEmail);
@@ -58,6 +59,7 @@ namespace Microsoft.AspNetCore.Identity.Test
{"identity:claimsidentity:securitystampclaimtype", securityStampClaimType},
{"identity:user:requireUniqueEmail", "true"},
{"identity:password:RequiredLength", "10"},
+ {"identity:password:RequiredUniqueChars", "5"},
{"identity:password:RequireNonAlphanumeric", "false"},
{"identity:password:RequireUpperCase", "false"},
{"identity:password:RequireDigit", "false"},
@@ -87,6 +89,7 @@ namespace Microsoft.AspNetCore.Identity.Test
Assert.False(options.Password.RequireNonAlphanumeric);
Assert.False(options.Password.RequireUppercase);
Assert.Equal(10, options.Password.RequiredLength);
+ Assert.Equal(5, options.Password.RequiredUniqueChars);
Assert.False(options.Lockout.AllowedForNewUsers);
Assert.Equal(1000, options.Lockout.MaxFailedAccessAttempts);
}
@@ -129,6 +132,7 @@ namespace Microsoft.AspNetCore.Identity.Test
Assert.True(myOptions.Password.RequireDigit);
Assert.True(myOptions.Password.RequireNonAlphanumeric);
Assert.True(myOptions.Password.RequireUppercase);
+ Assert.Equal(1, myOptions.Password.RequiredUniqueChars);
Assert.Equal(-1, myOptions.Password.RequiredLength);
}
diff --git a/test/Microsoft.AspNetCore.Identity.Test/PasswordValidatorTest.cs b/test/Microsoft.AspNetCore.Identity.Test/PasswordValidatorTest.cs
index 3817eb41e8..80fd04506f 100644
--- a/test/Microsoft.AspNetCore.Identity.Test/PasswordValidatorTest.cs
+++ b/test/Microsoft.AspNetCore.Identity.Test/PasswordValidatorTest.cs
@@ -97,6 +97,44 @@ namespace Microsoft.AspNetCore.Identity.Test
IdentityResultAssert.IsSuccess(await valid.ValidateAsync(manager, null, input));
}
+ [Theory]
+ [InlineData("a", 2)]
+ [InlineData("aaaaaaaaaaa", 2)]
+ [InlineData("abcdabcdabcdabcdabcdabcdabcd", 5)]
+ public async Task FailsWithoutRequiredUniqueCharsTests(string input, int uniqueChars)
+ {
+ var manager = MockHelpers.TestUserManager();
+ var valid = new PasswordValidator();
+ manager.Options.Password.RequireUppercase = false;
+ manager.Options.Password.RequireNonAlphanumeric = false;
+ manager.Options.Password.RequireLowercase = false;
+ manager.Options.Password.RequireDigit = false;
+ manager.Options.Password.RequiredLength = 0;
+ manager.Options.Password.RequiredUniqueChars = uniqueChars;
+ IdentityResultAssert.IsFailure(await valid.ValidateAsync(manager, null, input),
+ String.Format("Passwords must use at least {0} different characters.", uniqueChars));
+ }
+
+ [Theory]
+ [InlineData("12345", 5)]
+ [InlineData("aAbBc", 5)]
+ [InlineData("aAbBcaAbBcaAbBc", 5)]
+ [InlineData("!@#$%", 5)]
+ [InlineData("a", 1)]
+ [InlineData("this is a long password with many chars", 10)]
+ public async Task SucceedsWithRequiredUniqueCharsTests(string input, int uniqueChars)
+ {
+ var manager = MockHelpers.TestUserManager();
+ var valid = new PasswordValidator();
+ manager.Options.Password.RequireUppercase = false;
+ manager.Options.Password.RequireNonAlphanumeric = false;
+ manager.Options.Password.RequireLowercase = false;
+ manager.Options.Password.RequireDigit = false;
+ manager.Options.Password.RequiredLength = 0;
+ manager.Options.Password.RequiredUniqueChars = uniqueChars;
+ IdentityResultAssert.IsSuccess(await valid.ValidateAsync(manager, null, input));
+ }
+
[Theory]
[InlineData("abcde", Errors.Length | Errors.Alpha | Errors.Upper | Errors.Digit)]
[InlineData("a@B@cd", Errors.Digit)]