From addb9a45cbe02a924fd4709b6ca1fcce5d77137a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Fri, 10 Feb 2017 18:15:16 -0800 Subject: [PATCH] Implementing PasswordOptions.RequiredUniqueChars (#1100) Fixes #1097 --- .../IdentityErrorDescriber.cs | 14 +++++++ .../PasswordOptions.cs | 8 ++++ .../PasswordValidator.cs | 4 ++ .../Properties/Resources.Designer.cs | 16 ++++++++ .../Resources.resx | 4 ++ .../IdentityOptionsTest.cs | 4 ++ .../PasswordValidatorTest.cs | 38 +++++++++++++++++++ 7 files changed, 88 insertions(+) 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)]