From 626362d8a22625e82a2af1b7076796f161510a97 Mon Sep 17 00:00:00 2001 From: Hao Kung Date: Tue, 29 Jul 2014 13:45:16 -0700 Subject: [PATCH] Add NormalizedUserName / IUserNameNormalizer + Use normalized username for FindByUserName --- .../UserStore.cs | 29 +++++++++- .../IUserNameNormalizer.cs | 18 ++++++ src/Microsoft.AspNet.Identity/IUserStore.cs | 24 +++++++- .../IdentityServices.cs | 1 + src/Microsoft.AspNet.Identity/IdentityUser.cs | 1 + .../Microsoft.AspNet.Identity.kproj | 2 + .../UpperInvariantUserNameNormalizer.cs | 27 +++++++++ src/Microsoft.AspNet.Identity/UserManager.cs | 36 +++++++++++- src/Microsoft.AspNet.Identity/project.json | 1 + .../InMemoryUser.cs | 1 + .../InMemoryUserStore.cs | 23 ++++++++ .../InMemoryUserStore.cs | 11 ++++ .../NoopUserStore.cs | 11 ++++ .../UserManagerTest.cs | 58 +++++++++++++++---- test/Shared/IdentityConfig.cs | 14 ----- test/Shared/MockHelpers.cs | 14 ++++- 16 files changed, 235 insertions(+), 36 deletions(-) create mode 100644 src/Microsoft.AspNet.Identity/IUserNameNormalizer.cs create mode 100644 src/Microsoft.AspNet.Identity/UpperInvariantUserNameNormalizer.cs diff --git a/src/Microsoft.AspNet.Identity.EntityFramework/UserStore.cs b/src/Microsoft.AspNet.Identity.EntityFramework/UserStore.cs index 2b91ac76d6..173dbf30a4 100644 --- a/src/Microsoft.AspNet.Identity.EntityFramework/UserStore.cs +++ b/src/Microsoft.AspNet.Identity.EntityFramework/UserStore.cs @@ -114,6 +114,29 @@ namespace Microsoft.AspNet.Identity.EntityFramework return Task.FromResult(0); } + public Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken = new CancellationToken()) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) + { + throw new ArgumentNullException("user"); + } + return Task.FromResult(user.NormalizedUserName); + } + + public Task SetNormalizedUserNameAsync(TUser user, string userName, CancellationToken cancellationToken = new CancellationToken()) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) + { + throw new ArgumentNullException("user"); + } + user.NormalizedUserName = userName; + return Task.FromResult(0); + } + public async virtual Task CreateAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -183,16 +206,16 @@ namespace Microsoft.AspNet.Identity.EntityFramework } /// - /// Find a user by name + /// Find a user by normalized name /// /// /// /// - public virtual Task FindByNameAsync(string userName, CancellationToken cancellationToken = default(CancellationToken)) + public virtual Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - return GetUserAggregate(u => u.UserName.ToUpper() == userName.ToUpper(), cancellationToken); + return GetUserAggregate(u => u.NormalizedUserName == normalizedUserName, cancellationToken); } public IQueryable Users diff --git a/src/Microsoft.AspNet.Identity/IUserNameNormalizer.cs b/src/Microsoft.AspNet.Identity/IUserNameNormalizer.cs new file mode 100644 index 0000000000..b8d1bfcbf2 --- /dev/null +++ b/src/Microsoft.AspNet.Identity/IUserNameNormalizer.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Identity +{ + /// + /// Used to normalize a user name + /// + public interface IUserNameNormalizer + { + /// + /// Returns the normalized user name + /// + /// + /// + string Normalize(string userName); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Identity/IUserStore.cs b/src/Microsoft.AspNet.Identity/IUserStore.cs index 581dc5d246..bf27aec52f 100644 --- a/src/Microsoft.AspNet.Identity/IUserStore.cs +++ b/src/Microsoft.AspNet.Identity/IUserStore.cs @@ -39,6 +39,24 @@ namespace Microsoft.AspNet.Identity Task SetUserNameAsync(TUser user, string userName, CancellationToken cancellationToken = default(CancellationToken)); + /// + /// Returns the normalized user name + /// + /// + /// + /// + Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Set the normalized user name + /// + /// + /// + /// + /// + Task SetNormalizedUserNameAsync(TUser user, string userName, + CancellationToken cancellationToken = default(CancellationToken)); + /// /// Insert a new user /// @@ -72,11 +90,11 @@ namespace Microsoft.AspNet.Identity Task FindByIdAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)); /// - /// Returns the user associated with this name + /// Returns the user associated with this normalized user name /// - /// + /// /// /// - Task FindByNameAsync(string name, CancellationToken cancellationToken = default(CancellationToken)); + Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default(CancellationToken)); } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Identity/IdentityServices.cs b/src/Microsoft.AspNet.Identity/IdentityServices.cs index d66c8fed91..aa4ded7eda 100644 --- a/src/Microsoft.AspNet.Identity/IdentityServices.cs +++ b/src/Microsoft.AspNet.Identity/IdentityServices.cs @@ -26,6 +26,7 @@ namespace Microsoft.AspNet.Identity yield return describe.Transient, UserValidator>(); yield return describe.Transient, PasswordValidator>(); yield return describe.Transient, PasswordHasher>(); + yield return describe.Transient(); // TODO: rationalize email/sms/usertoken services } diff --git a/src/Microsoft.AspNet.Identity/IdentityUser.cs b/src/Microsoft.AspNet.Identity/IdentityUser.cs index b8b1be1bf1..b841e05020 100644 --- a/src/Microsoft.AspNet.Identity/IdentityUser.cs +++ b/src/Microsoft.AspNet.Identity/IdentityUser.cs @@ -36,6 +36,7 @@ namespace Microsoft.AspNet.Identity public virtual TKey Id { get; set; } public virtual string UserName { get; set; } + public virtual string NormalizedUserName { get; set; } /// /// Email diff --git a/src/Microsoft.AspNet.Identity/Microsoft.AspNet.Identity.kproj b/src/Microsoft.AspNet.Identity/Microsoft.AspNet.Identity.kproj index fa2772ca76..10083b8359 100644 --- a/src/Microsoft.AspNet.Identity/Microsoft.AspNet.Identity.kproj +++ b/src/Microsoft.AspNet.Identity/Microsoft.AspNet.Identity.kproj @@ -25,6 +25,8 @@ + + diff --git a/src/Microsoft.AspNet.Identity/UpperInvariantUserNameNormalizer.cs b/src/Microsoft.AspNet.Identity/UpperInvariantUserNameNormalizer.cs new file mode 100644 index 0000000000..1564ecd3d4 --- /dev/null +++ b/src/Microsoft.AspNet.Identity/UpperInvariantUserNameNormalizer.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNet.Identity +{ + /// + /// Normalizes user names via ToUpperInvariant() + /// + public class UpperInvariantUserNameNormalizer : IUserNameNormalizer + { + /// + /// Normalizes user names via ToUpperInvariant() + /// + /// + /// + public string Normalize(string userName) + { + if (userName == null) + { + return null; + } + return userName.Normalize().ToUpperInvariant(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Identity/UserManager.cs b/src/Microsoft.AspNet.Identity/UserManager.cs index 61c7b16876..077298fa70 100644 --- a/src/Microsoft.AspNet.Identity/UserManager.cs +++ b/src/Microsoft.AspNet.Identity/UserManager.cs @@ -38,7 +38,7 @@ namespace Microsoft.AspNet.Identity /// public UserManager(IUserStore store, IOptionsAccessor optionsAccessor, IPasswordHasher passwordHasher, IUserValidator userValidator, - IPasswordValidator passwordValidator) + IPasswordValidator passwordValidator, IUserNameNormalizer userNameNormalizer) { if (store == null) { @@ -57,6 +57,7 @@ namespace Microsoft.AspNet.Identity PasswordHasher = passwordHasher; UserValidator = userValidator; PasswordValidator = passwordValidator; + UserNameNormalizer = userNameNormalizer; // TODO: Email/Sms/Token services } @@ -96,6 +97,11 @@ namespace Microsoft.AspNet.Identity /// public IPasswordValidator PasswordValidator { get; set; } + /// + /// Used to normalize user names for uniqueness + /// + public IUserNameNormalizer UserNameNormalizer { get; set; } + /// /// Used to send email /// @@ -309,6 +315,7 @@ namespace Microsoft.AspNet.Identity { await GetUserLockoutStore().SetLockoutEnabledAsync(user, true, cancellationToken); } + await UpdateNormalizedUserName(user, cancellationToken); await Store.CreateAsync(user, cancellationToken); return IdentityResult.Success; } @@ -332,6 +339,7 @@ namespace Microsoft.AspNet.Identity { return result; } + await UpdateNormalizedUserName(user, cancellationToken); await Store.UpdateAsync(user, cancellationToken); return IdentityResult.Success; } @@ -381,6 +389,7 @@ namespace Microsoft.AspNet.Identity { throw new ArgumentNullException("userName"); } + userName = NormalizeUserName(userName); return Store.FindByNameAsync(userName, cancellationToken); } @@ -423,6 +432,29 @@ namespace Microsoft.AspNet.Identity return await CreateAsync(user, cancellationToken); } + /// + /// Normalize a user name for uniqueness comparisons + /// + /// + /// + public virtual string NormalizeUserName(string userName) + { + return (UserNameNormalizer == null) ? userName : UserNameNormalizer.Normalize(userName); + } + + /// + /// Update the user's normalized user name + /// + /// + /// + /// + public virtual async Task UpdateNormalizedUserName(TUser user, + CancellationToken cancellationToken = default(CancellationToken)) + { + string userName = await GetUserNameAsync(user, cancellationToken); + await Store.SetNormalizedUserNameAsync(user, NormalizeUserName(userName), cancellationToken); + } + /// /// Get the user's name /// @@ -456,6 +488,7 @@ namespace Microsoft.AspNet.Identity throw new ArgumentNullException("user"); } await Store.SetUserNameAsync(user, userName, cancellationToken); + await UpdateNormalizedUserName(user, cancellationToken); return await UpdateAsync(user, cancellationToken); } @@ -483,6 +516,7 @@ namespace Microsoft.AspNet.Identity CancellationToken cancellationToken = default(CancellationToken)) { ThrowIfDisposed(); + userName = NormalizeUserName(userName); var user = await FindByNameAsync(userName, cancellationToken); if (user == null) { diff --git a/src/Microsoft.AspNet.Identity/project.json b/src/Microsoft.AspNet.Identity/project.json index f9c78d70fc..3a8e4beca3 100644 --- a/src/Microsoft.AspNet.Identity/project.json +++ b/src/Microsoft.AspNet.Identity/project.json @@ -15,6 +15,7 @@ "System.Diagnostics.Debug": "4.0.10.0", "System.Diagnostics.Tools": "4.0.0.0", "System.Globalization": "4.0.10.0", + "System.Globalization.Extensions": "4.0.0.0", "System.Linq": "4.0.0.0", "System.Linq.Expressions": "4.0.0.0", "System.Linq.Queryable": "4.0.0.0", diff --git a/test/Microsoft.AspNet.Identity.EntityFramework.InMemory.Test/InMemoryUser.cs b/test/Microsoft.AspNet.Identity.EntityFramework.InMemory.Test/InMemoryUser.cs index 59ad8d4ea7..958077349c 100644 --- a/test/Microsoft.AspNet.Identity.EntityFramework.InMemory.Test/InMemoryUser.cs +++ b/test/Microsoft.AspNet.Identity.EntityFramework.InMemory.Test/InMemoryUser.cs @@ -35,6 +35,7 @@ namespace Microsoft.AspNet.Identity.EntityFramework.InMemory.Test public virtual TKey Id { get; set; } public virtual string UserName { get; set; } + public virtual string NormalizedUserName { get; set; } /// /// Email diff --git a/test/Microsoft.AspNet.Identity.EntityFramework.InMemory.Test/InMemoryUserStore.cs b/test/Microsoft.AspNet.Identity.EntityFramework.InMemory.Test/InMemoryUserStore.cs index 259b13dd15..636ffe2d96 100644 --- a/test/Microsoft.AspNet.Identity.EntityFramework.InMemory.Test/InMemoryUserStore.cs +++ b/test/Microsoft.AspNet.Identity.EntityFramework.InMemory.Test/InMemoryUserStore.cs @@ -117,6 +117,29 @@ namespace Microsoft.AspNet.Identity.EntityFramework.InMemory.Test return Task.FromResult(0); } + public Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken = new CancellationToken()) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) + { + throw new ArgumentNullException("user"); + } + return Task.FromResult(user.NormalizedUserName); + } + + public Task SetNormalizedUserNameAsync(TUser user, string userName, CancellationToken cancellationToken = new CancellationToken()) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) + { + throw new ArgumentNullException("user"); + } + user.NormalizedUserName = userName; + return Task.FromResult(0); + } + public async virtual Task CreateAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/test/Microsoft.AspNet.Identity.InMemory.Test/InMemoryUserStore.cs b/test/Microsoft.AspNet.Identity.InMemory.Test/InMemoryUserStore.cs index c3f7a4ba39..85a3f4ad4a 100644 --- a/test/Microsoft.AspNet.Identity.InMemory.Test/InMemoryUserStore.cs +++ b/test/Microsoft.AspNet.Identity.InMemory.Test/InMemoryUserStore.cs @@ -313,6 +313,17 @@ namespace Microsoft.AspNet.Identity.InMemory return Task.FromResult(user.TwoFactorEnabled); } + public Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(user.NormalizedUserName); + } + + public Task SetNormalizedUserNameAsync(TUser user, string userName, CancellationToken cancellationToken = default(CancellationToken)) + { + user.NormalizedUserName = userName; + return Task.FromResult(0); + } + private class LoginComparer : IEqualityComparer { public bool Equals(UserLoginInfo x, UserLoginInfo y) diff --git a/test/Microsoft.AspNet.Identity.Test/NoopUserStore.cs b/test/Microsoft.AspNet.Identity.Test/NoopUserStore.cs index 6a48080362..62943a8692 100644 --- a/test/Microsoft.AspNet.Identity.Test/NoopUserStore.cs +++ b/test/Microsoft.AspNet.Identity.Test/NoopUserStore.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Threading; using System.Threading.Tasks; @@ -51,5 +52,15 @@ namespace Microsoft.AspNet.Identity.Test { return Task.FromResult(0); } + + public Task GetNormalizedUserNameAsync(TestUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(null); + } + + public Task SetNormalizedUserNameAsync(TestUser user, string userName, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(0); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Identity.Test/UserManagerTest.cs b/test/Microsoft.AspNet.Identity.Test/UserManagerTest.cs index 66d60b45cb..ba8c556988 100644 --- a/test/Microsoft.AspNet.Identity.Test/UserManagerTest.cs +++ b/test/Microsoft.AspNet.Identity.Test/UserManagerTest.cs @@ -24,7 +24,7 @@ namespace Microsoft.AspNet.Identity.Test public TestManager(IUserStore store, IOptionsAccessor optionsAccessor, IPasswordHasher passwordHasher, IUserValidator userValidator, IPasswordValidator passwordValidator) - : base(store, optionsAccessor, passwordHasher, userValidator, passwordValidator) { } + : base(store, optionsAccessor, passwordHasher, userValidator, passwordValidator, null) { } } [Fact] @@ -130,13 +130,31 @@ namespace Microsoft.AspNet.Identity.Test } [Fact] - public async Task FindByNameCallsStore() + public async Task FindByNameCallsStoreWithNormalizedName() + { + // Setup + var store = new Mock>(); + var user = new TestUser {UserName="Foo"}; + store.Setup(s => s.FindByNameAsync(user.UserName.ToUpperInvariant(), CancellationToken.None)).Returns(Task.FromResult(user)).Verifiable(); + var userManager = MockHelpers.TestUserManager(store.Object); + + // Act + var result = await userManager.FindByNameAsync(user.UserName); + + // Assert + Assert.Equal(user, result); + store.VerifyAll(); + } + + [Fact] + public async Task CanFindByNameCallsStoreWithoutNormalizedName() { // Setup var store = new Mock>(); var user = new TestUser {UserName="Foo"}; store.Setup(s => s.FindByNameAsync(user.UserName, CancellationToken.None)).Returns(Task.FromResult(user)).Verifiable(); var userManager = MockHelpers.TestUserManager(store.Object); + userManager.UserNameNormalizer = null; // Act var result = await userManager.FindByNameAsync(user.UserName); @@ -333,14 +351,10 @@ namespace Microsoft.AspNet.Identity.Test Assert.False(manager.SupportsUserSecurityStamp); await Assert.ThrowsAsync(() => manager.UpdateSecurityStampAsync(null)); await Assert.ThrowsAsync(() => manager.GetSecurityStampAsync(null)); -#if NET45 - await - Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => manager.VerifyChangePhoneNumberTokenAsync(null, "1", "111-111-1111")); - await - Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => manager.GenerateChangePhoneNumberTokenAsync(null, "111-111-1111")); -#endif } [Fact] @@ -428,13 +442,13 @@ namespace Microsoft.AspNet.Identity.Test var passwordValidator = new PasswordValidator(); Assert.Throws("store", - () => new UserManager(null, null, null, null, null)); + () => new UserManager(null, null, null, null, null, null)); Assert.Throws("optionsAccessor", - () => new UserManager(store, null, null, null, null)); + () => new UserManager(store, null, null, null, null, null)); Assert.Throws("passwordHasher", - () => new UserManager(store, optionsAccessor, null, null, null)); + () => new UserManager(store, optionsAccessor, null, null, null, null)); - var manager = new UserManager(store, optionsAccessor, passwordHasher, userValidator, passwordValidator); + var manager = new UserManager(store, optionsAccessor, passwordHasher, userValidator, passwordValidator, null); Assert.Throws("value", () => manager.PasswordHasher = null); Assert.Throws("value", () => manager.Options = null); @@ -836,6 +850,16 @@ namespace Microsoft.AspNet.Identity.Test { return Task.FromResult(null); } + + public Task GetNormalizedUserNameAsync(TestUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(null); + } + + public Task SetNormalizedUserNameAsync(TestUser user, string userName, CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(0); + } } private class NoOpTokenProvider : IUserTokenProvider @@ -1076,6 +1100,16 @@ namespace Microsoft.AspNet.Identity.Test { throw new NotImplementedException(); } + + public Task GetNormalizedUserNameAsync(TestUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + throw new NotImplementedException(); + } + + public Task SetNormalizedUserNameAsync(TestUser user, string userName, CancellationToken cancellationToken = default(CancellationToken)) + { + throw new NotImplementedException(); + } } } } \ No newline at end of file diff --git a/test/Shared/IdentityConfig.cs b/test/Shared/IdentityConfig.cs index 75a74b1f36..be9b18d6a2 100644 --- a/test/Shared/IdentityConfig.cs +++ b/test/Shared/IdentityConfig.cs @@ -6,20 +6,6 @@ using Microsoft.Framework.OptionsModel; namespace Microsoft.AspNet.Identity.Test { - public class ApplicationUserManager : UserManager - { - public ApplicationUserManager(IUserStore store, IOptionsAccessor options, - IPasswordHasher passwordHasher, IUserValidator userValidator, - IPasswordValidator passwordValidator) - : base(store, options, passwordHasher, userValidator, passwordValidator) { } - } - - public class ApplicationRoleManager : RoleManager - { - public ApplicationRoleManager(IRoleStore store, IRoleValidator roleValidator) - : base(store, roleValidator) { } - } - public class ApplicationUser : IdentityUser { } diff --git a/test/Shared/MockHelpers.cs b/test/Shared/MockHelpers.cs index 0fa138d603..a09a87cc75 100644 --- a/test/Shared/MockHelpers.cs +++ b/test/Shared/MockHelpers.cs @@ -33,7 +33,13 @@ namespace Microsoft.AspNet.Identity.Test { var store = new Mock>(); var options = new OptionsAccessor(null); - return new Mock>(store.Object, options, new PasswordHasher(), new UserValidator(), new PasswordValidator()); + return new Mock>( + store.Object, + options, + new PasswordHasher(), + new UserValidator(), + new PasswordValidator(), + new UpperInvariantUserNameNormalizer()); } public static Mock> MockRoleManager() where TRole : class @@ -51,8 +57,10 @@ namespace Microsoft.AspNet.Identity.Test { var options = new OptionsAccessor(null); var validator = new Mock>(); - var userManager = new UserManager(store, options, new PasswordHasher(), validator.Object, new PasswordValidator()); - validator.Setup(v => v.ValidateAsync(userManager, It.IsAny(), CancellationToken.None)).Returns(Task.FromResult(IdentityResult.Success)).Verifiable(); + var userManager = new UserManager(store, options, new PasswordHasher(), + validator.Object, new PasswordValidator(), new UpperInvariantUserNameNormalizer()); + validator.Setup(v => v.ValidateAsync(userManager, It.IsAny(), CancellationToken.None)) + .Returns(Task.FromResult(IdentityResult.Success)).Verifiable(); return userManager; } }