From 9ecbefcf21d12d90ac187e1eefe02ed0219edc23 Mon Sep 17 00:00:00 2001 From: Hao Kung Date: Thu, 8 Mar 2018 12:13:34 -0800 Subject: [PATCH] ProtectPersonalData + extensiblity support (#1562) --- .../Data/ApplicationUser.cs | 8 +- src/Core/DefaultPersonalDataProtector.cs | 56 ++++ src/Core/ILookupProtector.cs | 27 ++ src/Core/ILookupProtectorKeyRing.cs | 31 +++ src/Core/IPersonalDataProtector.cs | 28 ++ src/Core/IProtectedUserStore.cs | 12 + src/Core/IdentityBuilder.cs | 15 + src/Core/PersonalDataAttribute.cs | 13 + src/Core/Properties/Resources.Designer.cs | 28 ++ src/Core/ProtectedPersonalDataAttribute.cs | 13 + src/Core/Resources.resx | 8 + src/Core/StoreOptions.cs | 6 + src/Core/UserManager.cs | 80 +++++- src/EF/IdentityUserContext.cs | 33 ++- src/EF/Properties/Resources.Designer.cs | 14 + src/EF/Resources.resx | 4 + src/EF/UserOnlyStore.cs | 3 +- src/EF/UserStore.cs | 3 +- .../Properties/Strings.Designer.cs | 160 +++++++++++ src/Service.Diagnostics/Strings.Designer.cs | 146 ---------- .../UserManagerSpecificationTests.cs | 3 +- src/Stores/IdentityUser.cs | 11 +- .../Account/Manage/DeletePersonalData.cshtml | 2 +- .../Manage/DownloadPersonalData.cshtml.cs | 14 +- .../Pages/Account/Manage/PersonalData.cshtml | 4 +- test/EF.Test/DbUtil.cs | 5 +- test/EF.Test/SqlStoreTestBase.cs | 25 +- .../UserStoreEncryptPersonalDataTest.cs | 262 ++++++++++++++++++ test/EF.Test/UserStoreStringKeyTest.cs | 1 - .../Infrastructure/DefaultUIContext.cs | 4 + .../ManagementTests.cs | 37 +++ .../Pages/Account/Manage/DeleteUser.cs | 41 +++ .../Pages/Account/Manage/Index.cs | 9 + .../Pages/Account/Manage/PersonalData.cs | 35 +++ test/Identity.FunctionalTests/UserStories.cs | 16 ++ test/Identity.Test/UserManagerTest.cs | 80 ++++++ 36 files changed, 1045 insertions(+), 192 deletions(-) create mode 100644 src/Core/DefaultPersonalDataProtector.cs create mode 100644 src/Core/ILookupProtector.cs create mode 100644 src/Core/ILookupProtectorKeyRing.cs create mode 100644 src/Core/IPersonalDataProtector.cs create mode 100644 src/Core/IProtectedUserStore.cs create mode 100644 src/Core/PersonalDataAttribute.cs create mode 100644 src/Core/ProtectedPersonalDataAttribute.cs create mode 100644 src/Service.Diagnostics/Properties/Strings.Designer.cs delete mode 100644 src/Service.Diagnostics/Strings.Designer.cs create mode 100644 test/EF.Test/UserStoreEncryptPersonalDataTest.cs create mode 100644 test/Identity.FunctionalTests/Pages/Account/Manage/DeleteUser.cs create mode 100644 test/Identity.FunctionalTests/Pages/Account/Manage/PersonalData.cs diff --git a/samples/IdentitySample.DefaultUI/Data/ApplicationUser.cs b/samples/IdentitySample.DefaultUI/Data/ApplicationUser.cs index 134677ce4f..cba3ed5f40 100644 --- a/samples/IdentitySample.DefaultUI/Data/ApplicationUser.cs +++ b/samples/IdentitySample.DefaultUI/Data/ApplicationUser.cs @@ -1,14 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; namespace IdentitySample.DefaultUI.Data { public class ApplicationUser : IdentityUser { + [ProtectedPersonalData] public string Name { get; set; } + [PersonalData] public int Age { get; set; } } } diff --git a/src/Core/DefaultPersonalDataProtector.cs b/src/Core/DefaultPersonalDataProtector.cs new file mode 100644 index 0000000000..6fef248043 --- /dev/null +++ b/src/Core/DefaultPersonalDataProtector.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. 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.AspNetCore.Identity +{ + /// + /// Default implementation of that uses + /// and to protect data with a payload format of {keyId}:{protectedData} + /// + public class DefaultPersonalDataProtector : IPersonalDataProtector + { + private readonly ILookupProtectorKeyRing _keyRing; + private readonly ILookupProtector _encryptor; + + /// + /// Constructor. + /// + /// + /// + public DefaultPersonalDataProtector(ILookupProtectorKeyRing keyRing, ILookupProtector protector) + { + _keyRing = keyRing; + _encryptor = protector; + } + + /// + /// Unprotect the data. + /// + /// The data to unprotect. + /// The unprotected data. + public virtual string Unprotect(string data) + { + var split = data.IndexOf(':'); + if (split == -1 || split == data.Length-1) + { + throw new InvalidOperationException("Malformed data."); + } + + var keyId = data.Substring(0, split); + return _encryptor.Unprotect(keyId, data.Substring(split + 1)); + } + + /// + /// Protect the data. + /// + /// The data to protect. + /// The protected data. + public virtual string Protect(string data) + { + var current = _keyRing.CurrentKeyId; + return current + ":" + _encryptor.Protect(current, data); + } + } +} \ No newline at end of file diff --git a/src/Core/ILookupProtector.cs b/src/Core/ILookupProtector.cs new file mode 100644 index 0000000000..21b1feec41 --- /dev/null +++ b/src/Core/ILookupProtector.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Identity +{ + /// + /// Used to protect/unprotect lookups with a specific key. + /// + public interface ILookupProtector + { + /// + /// Protect the data using the specified key. + /// + /// The key to use. + /// The data to protect. + /// The protected data. + string Protect(string keyId, string data); + + /// + /// Unprotect the data using the specified key. + /// + /// The key to use. + /// The data to unprotect. + /// The original data. + string Unprotect(string keyId, string data); + } +} \ No newline at end of file diff --git a/src/Core/ILookupProtectorKeyRing.cs b/src/Core/ILookupProtectorKeyRing.cs new file mode 100644 index 0000000000..a25f7c7ef5 --- /dev/null +++ b/src/Core/ILookupProtectorKeyRing.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Identity +{ + /// + /// Abstraction used to manage named keys used to protect lookups. + /// + public interface ILookupProtectorKeyRing + { + /// + /// Get the current key id. + /// + string CurrentKeyId { get; } + + /// + /// Return a specific key. + /// + /// The id of the key to fetch. + /// The key ring. + string this[string keyId] { get; } + + /// + /// Return all of the key ids. + /// + /// All of the key ids. + IEnumerable GetAllKeyIds(); + } +} \ No newline at end of file diff --git a/src/Core/IPersonalDataProtector.cs b/src/Core/IPersonalDataProtector.cs new file mode 100644 index 0000000000..6c543aecfa --- /dev/null +++ b/src/Core/IPersonalDataProtector.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. 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.Collections.Generic; + +namespace Microsoft.AspNetCore.Identity +{ + /// + /// Provides an abstraction used for personal data encryption. + /// + public interface IPersonalDataProtector + { + /// + /// Protect the data. + /// + /// The data to protect. + /// The protected data. + string Protect(string data); + + /// + /// Unprotect the data. + /// + /// + /// The unprotected data. + string Unprotect(string data); + } +} \ No newline at end of file diff --git a/src/Core/IProtectedUserStore.cs b/src/Core/IProtectedUserStore.cs new file mode 100644 index 0000000000..332f0a5916 --- /dev/null +++ b/src/Core/IProtectedUserStore.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Identity +{ + /// + /// Marker interface used to signal that the store supports the flag. + /// + /// The type that represents a user. + public interface IProtectedUserStore : IUserStore where TUser : class + { } +} \ No newline at end of file diff --git a/src/Core/IdentityBuilder.cs b/src/Core/IdentityBuilder.cs index 0d26dfccac..2c75df148e 100644 --- a/src/Core/IdentityBuilder.cs +++ b/src/Core/IdentityBuilder.cs @@ -184,6 +184,21 @@ namespace Microsoft.AspNetCore.Identity return AddScoped(typeof(IRoleValidator<>).MakeGenericType(RoleType), typeof(TRole)); } + /// + /// Adds an and . + /// + /// The personal data protector type. + /// The personal data protector key ring type. + /// The current instance. + public virtual IdentityBuilder AddPersonalDataProtection() + where TProtector : class,ILookupProtector + where TKeyRing : class, ILookupProtectorKeyRing + { + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(); + return this; + } /// /// Adds a for the . diff --git a/src/Core/PersonalDataAttribute.cs b/src/Core/PersonalDataAttribute.cs new file mode 100644 index 0000000000..5ee23537fa --- /dev/null +++ b/src/Core/PersonalDataAttribute.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. 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.AspNetCore.Identity +{ + /// + /// Used to indicate that a something is considered personal data. + /// + public class PersonalDataAttribute : Attribute + { } +} \ No newline at end of file diff --git a/src/Core/Properties/Resources.Designer.cs b/src/Core/Properties/Resources.Designer.cs index 5cef01d32d..57a5e5b702 100644 --- a/src/Core/Properties/Resources.Designer.cs +++ b/src/Core/Properties/Resources.Designer.cs @@ -682,6 +682,34 @@ namespace Microsoft.Extensions.Identity.Core internal static string FormatNoRoleType() => GetString("NoRoleType"); + /// + /// Store does not implement IProtectedUserStore<TUser> which is required when ProtectPersonalData = true. + /// + internal static string StoreNotIProtectedUserStore + { + get => GetString("StoreNotIProtectedUserStore"); + } + + /// + /// Store does not implement IProtectedUserStore<TUser> which is required when ProtectPersonalData = true. + /// + internal static string FormatStoreNotIProtectedUserStore() + => GetString("StoreNotIProtectedUserStore"); + + /// + /// No IPersonalDataProtector service was registered, this is required when ProtectPersonalData = true. + /// + internal static string NoPersonalDataProtector + { + get => GetString("NoPersonalDataProtector"); + } + + /// + /// No IPersonalDataProtector service was registered, this is required when ProtectPersonalData = true. + /// + internal static string FormatNoPersonalDataProtector() + => GetString("NoPersonalDataProtector"); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Core/ProtectedPersonalDataAttribute.cs b/src/Core/ProtectedPersonalDataAttribute.cs new file mode 100644 index 0000000000..36ab193a42 --- /dev/null +++ b/src/Core/ProtectedPersonalDataAttribute.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. 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.AspNetCore.Identity +{ + /// + /// Used to indicate that a something is considered personal data and should be protected. + /// + public class ProtectedPersonalDataAttribute : PersonalDataAttribute + { } +} \ No newline at end of file diff --git a/src/Core/Resources.resx b/src/Core/Resources.resx index 50d6501478..f221b9ec6a 100644 --- a/src/Core/Resources.resx +++ b/src/Core/Resources.resx @@ -309,4 +309,12 @@ No RoleType was specified, try AddRoles<TRole>(). Error when the IdentityBuilder.RoleType was not specified + + Store does not implement IProtectedUserStore<TUser> which is required when ProtectPersonalData = true. + Error when the store does not implement this interface + + + No IPersonalDataProtector service was registered, this is required when ProtectPersonalData = true. + Error when there is no IPersonalDataProtector + \ No newline at end of file diff --git a/src/Core/StoreOptions.cs b/src/Core/StoreOptions.cs index c8d09f1ba3..78b74cc8cc 100644 --- a/src/Core/StoreOptions.cs +++ b/src/Core/StoreOptions.cs @@ -13,5 +13,11 @@ namespace Microsoft.AspNetCore.Identity /// properties used as keys, i.e. UserId, LoginProvider, ProviderKey. /// public int MaxLengthForKeys { get; set; } + + /// + /// If set to true, the store must protect all personally identifying data for a user. + /// This will be enforced by requiring the store to implement . + /// + public bool ProtectPersonalData { get; set; } } } \ No newline at end of file diff --git a/src/Core/UserManager.cs b/src/Core/UserManager.cs index cec8d8eb7e..5d67f1f096 100644 --- a/src/Core/UserManager.cs +++ b/src/Core/UserManager.cs @@ -44,7 +44,7 @@ namespace Microsoft.AspNetCore.Identity private TimeSpan _defaultLockout = TimeSpan.Zero; private bool _disposed; private static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); - + private IServiceProvider _services; /// /// The cancellation token used to cancel operations. @@ -99,6 +99,7 @@ namespace Microsoft.AspNetCore.Identity } } + _services = services; if (services != null) { foreach (var providerName in Options.Tokens.ProviderMap.Keys) @@ -113,6 +114,18 @@ namespace Microsoft.AspNetCore.Identity } } } + + if (Options.Stores.ProtectPersonalData) + { + if (!(Store is IProtectedUserStore)) + { + throw new InvalidOperationException(Resources.StoreNotIProtectedUserStore); + } + if (services.GetService() == null) + { + throw new InvalidOperationException(Resources.NoPersonalDataProtector); + } + } } /// @@ -527,7 +540,7 @@ namespace Microsoft.AspNetCore.Identity /// /// The that represents the asynchronous operation, containing the user matching the specified if it exists. /// - public virtual Task FindByNameAsync(string userName) + public virtual async Task FindByNameAsync(string userName) { ThrowIfDisposed(); if (userName == null) @@ -535,7 +548,28 @@ namespace Microsoft.AspNetCore.Identity throw new ArgumentNullException(nameof(userName)); } userName = NormalizeKey(userName); - return Store.FindByNameAsync(userName, CancellationToken); + + var user = await Store.FindByNameAsync(userName, CancellationToken); + + // Need to potentially check all keys + if (user == null && Options.Stores.ProtectPersonalData) + { + var keyRing = _services.GetService(); + var protector = _services.GetService(); + if (keyRing != null && protector != null) + { + foreach (var key in keyRing.GetAllKeyIds()) + { + var oldKey = protector.Protect(key, userName); + user = await Store.FindByNameAsync(oldKey, CancellationToken); + if (user != null) + { + return user; + } + } + } + } + return user; } /// @@ -578,6 +612,17 @@ namespace Microsoft.AspNetCore.Identity return (KeyNormalizer == null) ? key : KeyNormalizer.Normalize(key); } + private string ProtectPersonalData(string data) + { + if (Options.Stores.ProtectPersonalData) + { + var keyRing = _services.GetService(); + var protector = _services.GetService(); + return protector.Protect(keyRing.CurrentKeyId, data); + } + return data; + } + /// /// Updates the normalized user name for the specified . /// @@ -586,6 +631,7 @@ namespace Microsoft.AspNetCore.Identity public virtual async Task UpdateNormalizedUserNameAsync(TUser user) { var normalizedName = NormalizeKey(await GetUserNameAsync(user)); + normalizedName = ProtectPersonalData(normalizedName); await Store.SetNormalizedUserNameAsync(user, normalizedName, CancellationToken); } @@ -1352,7 +1398,7 @@ namespace Microsoft.AspNetCore.Identity /// /// The task object containing the results of the asynchronous lookup operation, the user, if any, associated with a normalized value of the specified email address. /// - public virtual Task FindByEmailAsync(string email) + public virtual async Task FindByEmailAsync(string email) { ThrowIfDisposed(); var store = GetEmailStore(); @@ -1360,7 +1406,29 @@ namespace Microsoft.AspNetCore.Identity { throw new ArgumentNullException(nameof(email)); } - return store.FindByEmailAsync(NormalizeKey(email), CancellationToken); + + email = NormalizeKey(email); + var user = await store.FindByEmailAsync(email, CancellationToken); + + // Need to potentially check all keys + if (user == null && Options.Stores.ProtectPersonalData) + { + var keyRing = _services.GetService(); + var protector = _services.GetService(); + if (keyRing != null && protector != null) + { + foreach (var key in keyRing.GetAllKeyIds()) + { + var oldKey = protector.Protect(key, email); + user = await store.FindByEmailAsync(oldKey, CancellationToken); + if (user != null) + { + return user; + } + } + } + } + return user; } /// @@ -1374,7 +1442,7 @@ namespace Microsoft.AspNetCore.Identity if (store != null) { var email = await GetEmailAsync(user); - await store.SetNormalizedEmailAsync(user, NormalizeKey(email), CancellationToken); + await store.SetNormalizedEmailAsync(user, ProtectPersonalData(NormalizeKey(email)), CancellationToken); } } diff --git a/src/EF/IdentityUserContext.cs b/src/EF/IdentityUserContext.cs index 65ccab3f7a..6415047f3a 100644 --- a/src/EF/IdentityUserContext.cs +++ b/src/EF/IdentityUserContext.cs @@ -3,9 +3,9 @@ using System; using System.Linq; -using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.Converters; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -96,16 +96,16 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore /// public DbSet UserTokens { get; set; } - private int GetMaxLengthForKeys() - { - // Need to get the actual application service provider, fallback will cause - // options to not work since IEnumerable don't flow across providers - var options = this.GetService() + private StoreOptions GetStoreOptions() => this.GetService() .Extensions.OfType() .FirstOrDefault()?.ApplicationServiceProvider ?.GetService>() ?.Value?.Stores; - return options != null ? options.MaxLengthForKeys : 0; + + private class PersonalDataConverter : ValueConverter + { + public PersonalDataConverter(IPersonalDataProtector protector) : base(s => protector.Protect(s), s => protector.Unprotect(s), default) + { } } /// @@ -116,7 +116,9 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore /// protected override void OnModelCreating(ModelBuilder builder) { - var maxKeyLength = GetMaxLengthForKeys(); + var storeOptions = GetStoreOptions(); + var maxKeyLength = storeOptions?.MaxLengthForKeys ?? 0; + var encryptPersonalData = storeOptions?.ProtectPersonalData ?? false; builder.Entity(b => { @@ -131,6 +133,21 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore b.Property(u => u.Email).HasMaxLength(256); b.Property(u => u.NormalizedEmail).HasMaxLength(256); + if (encryptPersonalData) + { + var converter = new PersonalDataConverter(this.GetService()); + var personalDataProps = typeof(TUser).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(ProtectedPersonalDataAttribute))); + foreach (var p in personalDataProps) + { + if (p.PropertyType != typeof(string)) + { + throw new InvalidOperationException(Resources.CanOnlyProtectStrings); + } + b.Property(typeof(string), p.Name).HasConversion(converter); + } + } + b.HasMany().WithOne().HasForeignKey(uc => uc.UserId).IsRequired(); b.HasMany().WithOne().HasForeignKey(ul => ul.UserId).IsRequired(); b.HasMany().WithOne().HasForeignKey(ut => ut.UserId).IsRequired(); diff --git a/src/EF/Properties/Resources.Designer.cs b/src/EF/Properties/Resources.Designer.cs index 7db34e9f37..7cc882ef40 100644 --- a/src/EF/Properties/Resources.Designer.cs +++ b/src/EF/Properties/Resources.Designer.cs @@ -10,6 +10,20 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore private static readonly ResourceManager _resourceManager = new ResourceManager("Microsoft.AspNetCore.Identity.EntityFrameworkCore.Resources", typeof(Resources).GetTypeInfo().Assembly); + /// + /// [ProtectedPersonalData] only works strings by default. + /// + internal static string CanOnlyProtectStrings + { + get => GetString("CanOnlyProtectStrings"); + } + + /// + /// [ProtectedPersonalData] only works strings by default. + /// + internal static string FormatCanOnlyProtectStrings() + => GetString("CanOnlyProtectStrings"); + /// /// AddEntityFrameworkStores can only be called with a role that derives from IdentityRole<TKey>. /// diff --git a/src/EF/Resources.resx b/src/EF/Resources.resx index 9bde296e06..1f85b1395e 100644 --- a/src/EF/Resources.resx +++ b/src/EF/Resources.resx @@ -117,6 +117,10 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + [ProtectedPersonalData] only works strings by default. + error when attribute is used on a non string property. + AddEntityFrameworkStores can only be called with a role that derives from IdentityRole<TKey>. error when the role does not derive from IdentityRole diff --git a/src/EF/UserOnlyStore.cs b/src/EF/UserOnlyStore.cs index e0a36a98cf..614d4fbf6d 100644 --- a/src/EF/UserOnlyStore.cs +++ b/src/EF/UserOnlyStore.cs @@ -83,7 +83,8 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore IUserTwoFactorStore, IUserAuthenticationTokenStore, IUserAuthenticatorKeyStore, - IUserTwoFactorRecoveryCodeStore + IUserTwoFactorRecoveryCodeStore, + IProtectedUserStore where TUser : IdentityUser where TContext : DbContext where TKey : IEquatable diff --git a/src/EF/UserStore.cs b/src/EF/UserStore.cs index cedb9f4bae..453cb6efec 100644 --- a/src/EF/UserStore.cs +++ b/src/EF/UserStore.cs @@ -94,7 +94,8 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore /// The type representing a user token. /// The type representing a role claim. public class UserStore : - UserStoreBase + UserStoreBase, + IProtectedUserStore where TUser : IdentityUser where TRole : IdentityRole where TContext : DbContext diff --git a/src/Service.Diagnostics/Properties/Strings.Designer.cs b/src/Service.Diagnostics/Properties/Strings.Designer.cs new file mode 100644 index 0000000000..4b54882c25 --- /dev/null +++ b/src/Service.Diagnostics/Properties/Strings.Designer.cs @@ -0,0 +1,160 @@ +// +namespace Microsoft.AspNetCore.Diagnostics.Identity.Service +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Strings + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.AspNetCore.Diagnostics.Identity.Service.Strings", typeof(Strings).GetTypeInfo().Assembly); + + /// + /// Developer certificate + /// + internal static string CertificateErrorPage_Title + { + get => GetString("CertificateErrorPage_Title"); + } + + /// + /// Developer certificate + /// + internal static string FormatCertificateErrorPage_Title() + => GetString("CertificateErrorPage_Title"); + + /// + /// Create certificate + /// + internal static string CreateCertificate + { + get => GetString("CreateCertificate"); + } + + /// + /// Create certificate + /// + internal static string FormatCreateCertificate() + => GetString("CreateCertificate"); + + /// + /// Done + /// + internal static string CreateCertificateDone + { + get => GetString("CreateCertificateDone"); + } + + /// + /// Done + /// + internal static string FormatCreateCertificateDone() + => GetString("CreateCertificateDone"); + + /// + /// Certificate creation failed. + /// + internal static string CreateCertificateFailed + { + get => GetString("CreateCertificateFailed"); + } + + /// + /// Certificate creation failed. + /// + internal static string FormatCreateCertificateFailed() + => GetString("CreateCertificateFailed"); + + /// + /// Certificate creation succeeded. Try refreshing the page. + /// + internal static string CreateCertificateRefresh + { + get => GetString("CreateCertificateRefresh"); + } + + /// + /// Certificate creation succeeded. Try refreshing the page. + /// + internal static string FormatCreateCertificateRefresh() + => GetString("CreateCertificateRefresh"); + + /// + /// Creating developer certificate... + /// + internal static string CreateCertificateRunning + { + get => GetString("CreateCertificateRunning"); + } + + /// + /// Creating developer certificate... + /// + internal static string FormatCreateCertificateRunning() + => GetString("CreateCertificateRunning"); + + /// + /// Identity requires a certificate to sign tokens. You can create a developer certificate by clicking the Create Certificate button to generate a developer certificate for you automatically. This will create a self-signed certificate with subject IdentityService.Development and will add it to your current user personal store. + /// Alternatively, you can create this certificate manually with the instructions given in the following link: + /// + /// + internal static string ManualCertificateGenerationInfo + { + get => GetString("ManualCertificateGenerationInfo"); + } + + /// + /// Identity requires a certificate to sign tokens. You can create a developer certificate by clicking the Create Certificate button to generate a developer certificate for you automatically. This will create a self-signed certificate with subject IdentityService.Development and will add it to your current user personal store. + /// Alternatively, you can create this certificate manually with the instructions given in the following link: + /// + /// + internal static string FormatManualCertificateGenerationInfo() + => GetString("ManualCertificateGenerationInfo"); + + /// + /// https://go.microsoft.com/fwlink/?linkid=848037 + /// + internal static string ManualCertificateGenerationInfoLink + { + get => GetString("ManualCertificateGenerationInfoLink"); + } + + /// + /// https://go.microsoft.com/fwlink/?linkid=848037 + /// + internal static string FormatManualCertificateGenerationInfoLink() + => GetString("ManualCertificateGenerationInfoLink"); + + /// + /// The developer certificate is missing or invalid + /// + internal static string MissingOrInvalidCertificate + { + get => GetString("MissingOrInvalidCertificate"); + } + + /// + /// The developer certificate is missing or invalid + /// + internal static string FormatMissingOrInvalidCertificate() + => GetString("MissingOrInvalidCertificate"); + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/Service.Diagnostics/Strings.Designer.cs b/src/Service.Diagnostics/Strings.Designer.cs deleted file mode 100644 index 3249c864c4..0000000000 --- a/src/Service.Diagnostics/Strings.Designer.cs +++ /dev/null @@ -1,146 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace Microsoft.AspNetCore.Diagnostics.Identity.Service { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Strings { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Strings() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.AspNetCore.Diagnostics.Identity.Service.Strings", typeof(Strings).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized string similar to Developer certificate. - /// - internal static string CertificateErrorPage_Title { - get { - return ResourceManager.GetString("CertificateErrorPage_Title", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Create certificate. - /// - internal static string CreateCertificate { - get { - return ResourceManager.GetString("CreateCertificate", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Done. - /// - internal static string CreateCertificateDone { - get { - return ResourceManager.GetString("CreateCertificateDone", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Certificate creation failed.. - /// - internal static string CreateCertificateFailed { - get { - return ResourceManager.GetString("CreateCertificateFailed", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Certificate creation succeeded. Try refreshing the page.. - /// - internal static string CreateCertificateRefresh { - get { - return ResourceManager.GetString("CreateCertificateRefresh", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Creating developer certificate.... - /// - internal static string CreateCertificateRunning { - get { - return ResourceManager.GetString("CreateCertificateRunning", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Identity requires a certificate to sign tokens. You can create a developer certificate by clicking the Create Certificate button to generate a developer certificate for you automatically. This will create a self-signed certificate with subject IdentityService.Development and will add it to your current user personal store. - /// Alternatively, you can create this certificate manually with the instructions given in the following link: - /// . - /// - internal static string ManualCertificateGenerationInfo { - get { - return ResourceManager.GetString("ManualCertificateGenerationInfo", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to https://go.microsoft.com/fwlink/?linkid=848037. - /// - internal static string ManualCertificateGenerationInfoLink { - get { - return ResourceManager.GetString("ManualCertificateGenerationInfoLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The developer certificate is missing or invalid. - /// - internal static string MissingOrInvalidCertificate { - get { - return ResourceManager.GetString("MissingOrInvalidCertificate", resourceCulture); - } - } - } -} diff --git a/src/Specification.Tests/UserManagerSpecificationTests.cs b/src/Specification.Tests/UserManagerSpecificationTests.cs index 99c4049c05..69b9bbfda7 100644 --- a/src/Specification.Tests/UserManagerSpecificationTests.cs +++ b/src/Specification.Tests/UserManagerSpecificationTests.cs @@ -1020,12 +1020,13 @@ namespace Microsoft.AspNetCore.Identity.Test /// /// Task [Fact] - public async Task CanFindUsersViaUserQuerable() + public async virtual Task CanFindUsersViaUserQuerable() { if (ShouldSkipDbTests()) { return; } + var mgr = CreateManager(); if (mgr.SupportsQueryableUsers) { diff --git a/src/Stores/IdentityUser.cs b/src/Stores/IdentityUser.cs index 7143fadc18..e95bb62366 100644 --- a/src/Stores/IdentityUser.cs +++ b/src/Stores/IdentityUser.cs @@ -57,11 +57,13 @@ namespace Microsoft.AspNetCore.Identity /// /// Gets or sets the primary key for this user. /// + [PersonalData] public virtual TKey Id { get; set; } /// /// Gets or sets the user name for this user. /// + [ProtectedPersonalData] public virtual string UserName { get; set; } /// @@ -72,6 +74,7 @@ namespace Microsoft.AspNetCore.Identity /// /// Gets or sets the email address for this user. /// + [ProtectedPersonalData] public virtual string Email { get; set; } /// @@ -83,6 +86,7 @@ namespace Microsoft.AspNetCore.Identity /// Gets or sets a flag indicating if a user has confirmed their email address. /// /// True if the email address has been confirmed, otherwise false. + [PersonalData] public virtual bool EmailConfirmed { get; set; } /// @@ -103,18 +107,21 @@ namespace Microsoft.AspNetCore.Identity /// /// Gets or sets a telephone number for the user. /// + [ProtectedPersonalData] public virtual string PhoneNumber { get; set; } /// /// Gets or sets a flag indicating if a user has confirmed their telephone address. /// /// True if the telephone number has been confirmed, otherwise false. + [PersonalData] public virtual bool PhoneNumberConfirmed { get; set; } /// /// Gets or sets a flag indicating if two factor authentication is enabled for this user. /// /// True if 2fa is enabled, otherwise false. + [PersonalData] public virtual bool TwoFactorEnabled { get; set; } /// @@ -140,8 +147,6 @@ namespace Microsoft.AspNetCore.Identity /// Returns the username for this user. /// public override string ToString() - { - return UserName; - } + => UserName; } } diff --git a/src/UI/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml b/src/UI/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml index 709701fc75..e76617d469 100644 --- a/src/UI/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml +++ b/src/UI/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml @@ -15,7 +15,7 @@
-
+
@if (Model.RequirePassword) { diff --git a/src/UI/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs b/src/UI/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs index 09055967dd..d7bcb2d231 100644 --- a/src/UI/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs +++ b/src/UI/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -43,12 +44,13 @@ namespace Microsoft.AspNetCore.Identity.UI.Pages.Account.Manage.Internal // Only include personal data for download var personalData = new Dictionary(); - personalData.Add("UserId", await _userManager.GetUserIdAsync(user)); - personalData.Add("UserName", await _userManager.GetUserNameAsync(user)); - personalData.Add("Email", await _userManager.GetEmailAsync(user)); - personalData.Add("EmailConfirmed", (await _userManager.IsEmailConfirmedAsync(user)).ToString()); - personalData.Add("PhoneNumber", await _userManager.GetPhoneNumberAsync(user)); - personalData.Add("PhoneNumberConfirmed", (await _userManager.IsPhoneNumberConfirmedAsync(user)).ToString()); + + var personalDataProps = typeof(TUser).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute))); + foreach (var p in personalDataProps) + { + personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null"); + } Response.Headers.Add("Content-Disposition", "attachment; filename=PersonalData.json"); return new FileContentResult(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(personalData)), "text/json"); diff --git a/src/UI/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml b/src/UI/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml index 770f044cf0..a35ee9b068 100644 --- a/src/UI/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml +++ b/src/UI/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml @@ -13,11 +13,11 @@

Deleting this data will permanently remove your account, and this cannot be recovered.

- +

- Delete + Delete

diff --git a/test/EF.Test/DbUtil.cs b/test/EF.Test/DbUtil.cs index 915c824dfc..608b115276 100644 --- a/test/EF.Test/DbUtil.cs +++ b/test/EF.Test/DbUtil.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. 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.Data.SqlClient; using System.Linq; using Microsoft.EntityFrameworkCore; @@ -25,9 +26,9 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test return services; } - public static TContext Create(string connectionString) where TContext : DbContext + public static TContext Create(string connectionString, IServiceCollection services = null) where TContext : DbContext { - var serviceProvider = ConfigureDbServices(connectionString).BuildServiceProvider(); + var serviceProvider = ConfigureDbServices(connectionString, services).BuildServiceProvider(); return serviceProvider.GetRequiredService(); } diff --git a/test/EF.Test/SqlStoreTestBase.cs b/test/EF.Test/SqlStoreTestBase.cs index 258caf98c0..1a2ca28f41 100644 --- a/test/EF.Test/SqlStoreTestBase.cs +++ b/test/EF.Test/SqlStoreTestBase.cs @@ -24,20 +24,15 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test where TRole : IdentityRole, new() where TKey : IEquatable { - private readonly ScratchDatabaseFixture _fixture; + protected readonly ScratchDatabaseFixture _fixture; protected SqlStoreTestBase(ScratchDatabaseFixture fixture) { _fixture = fixture; } - protected override void SetupIdentityServices(IServiceCollection services, object context) + protected virtual void SetupAddIdentity(IServiceCollection services) { - services.AddHttpContextAccessor(); - services.AddSingleton((TestDbContext)context); - services.AddLogging(); - services.AddSingleton>>(new TestLogger>()); - services.AddSingleton>>(new TestLogger>()); services.AddIdentity(options => { options.Password.RequireDigit = false; @@ -50,6 +45,16 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test .AddEntityFrameworkStores(); } + protected override void SetupIdentityServices(IServiceCollection services, object context) + { + services.AddHttpContextAccessor(); + services.AddSingleton((TestDbContext)context); + services.AddLogging(); + services.AddSingleton>>(new TestLogger>()); + services.AddSingleton>>(new TestLogger>()); + SetupAddIdentity(services); + } + protected override bool ShouldSkipDbTests() { return TestPlatformHelper.IsMono || !TestPlatformHelper.IsWindows; @@ -86,9 +91,11 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test protected override Expression> UserNameStartsWithPredicate(string userName) => u => u.UserName.StartsWith(userName); - public TestDbContext CreateContext() + public virtual TestDbContext CreateContext() { - var db = DbUtil.Create(_fixture.ConnectionString); + var services = new ServiceCollection(); + SetupAddIdentity(services); + var db = DbUtil.Create(_fixture.ConnectionString, services); db.Database.EnsureCreated(); return db; } diff --git a/test/EF.Test/UserStoreEncryptPersonalDataTest.cs b/test/EF.Test/UserStoreEncryptPersonalDataTest.cs new file mode 100644 index 0000000000..5d9114cc61 --- /dev/null +++ b/test/EF.Test/UserStoreEncryptPersonalDataTest.cs @@ -0,0 +1,262 @@ +// Copyright (c) .NET Foundation. 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.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity.Test; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test +{ + public class ProtectedUserStoreTest : SqlStoreTestBase + { + private DefaultKeyRing _keyRing = new DefaultKeyRing(); + + public ProtectedUserStoreTest(ScratchDatabaseFixture fixture) + : base(fixture) + { } + + protected override void SetupAddIdentity(IServiceCollection services) + { + services.AddIdentity(options => + { + options.Stores.ProtectPersonalData = true; + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.User.AllowedUserNameCharacters = null; + }) + .AddDefaultTokenProviders() + .AddEntityFrameworkStores() + .AddPersonalDataProtection(); + } + + public class DefaultKeyRing : ILookupProtectorKeyRing + { + public static string Current = "Default"; + public string this[string keyId] => keyId; + public string CurrentKeyId => Current; + + public IEnumerable GetAllKeyIds() + { + return new string[] { "Default", "NewPad" }; + } + } + + private class SillyEncryptor : ILookupProtector + { + private readonly ILookupProtectorKeyRing _keyRing; + + public SillyEncryptor(ILookupProtectorKeyRing keyRing) => _keyRing = keyRing; + + public string Unprotect(string keyId, string data) + { + var pad = _keyRing[keyId]; + if (!data.StartsWith(pad)) + { + throw new InvalidOperationException("Didn't find pad."); + } + return data.Substring(pad.Length); + } + + public string Protect(string keyId, string data) + => _keyRing[keyId] + data; + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task CanRotateKeysAndStillFind() + { + if (ShouldSkipDbTests()) + { + return; + } + var manager = CreateManager(); + var name = Guid.NewGuid().ToString(); + var user = CreateTestUser(name); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + IdentityResultAssert.IsSuccess(await manager.SetEmailAsync(user, "hao@hao.com")); + var newName = Guid.NewGuid().ToString(); + Assert.Null(await manager.FindByNameAsync(newName)); + IdentityResultAssert.IsSuccess(await manager.SetPhoneNumberAsync(user, "123-456-7890")); + + Assert.Equal(user, await manager.FindByEmailAsync("hao@hao.com")); + + IdentityResultAssert.IsSuccess(await manager.SetUserNameAsync(user, newName)); + IdentityResultAssert.IsSuccess(await manager.UpdateAsync(user)); + Assert.NotNull(await manager.FindByNameAsync(newName)); + Assert.Null(await manager.FindByNameAsync(name)); + DefaultKeyRing.Current = "NewPad"; + Assert.NotNull(await manager.FindByNameAsync(newName)); + Assert.Equal(user, await manager.FindByEmailAsync("hao@hao.com")); + Assert.Equal("123-456-7890", await manager.GetPhoneNumberAsync(user)); + } + + private class InkProtector : ILookupProtector + { + public InkProtector() { } + + public string Unprotect(string keyId, string data) + => "ink"; + + public string Protect(string keyId, string data) + => "ink"; + } + + private class CustomUser : IdentityUser + { + [ProtectedPersonalData] + public string PersonalData1 { get; set; } + public string NonPersonalData1 { get; set; } + [ProtectedPersonalData] + public string PersonalData2 { get; set; } + public string NonPersonalData2 { get; set; } + [PersonalData] + public string SafePersonalData { get; set; } + } + + private bool FindInk(DbConnection conn, string column, string id) + { + using (var command = conn.CreateCommand()) + { + command.CommandText = $"SELECT u.{column} FROM AspNetUsers u WHERE u.Id = '{id}'"; + command.CommandType = System.Data.CommandType.Text; + using (var reader = command.ExecuteReader()) + { + if (reader.Read()) + { + return reader.GetString(0) == "Default:ink"; + } + } + } + return false; + } + + /// + /// Test. + /// + /// Task + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CustomPersonalDataPropertiesAreProtected(bool protect) + { + if (ShouldSkipDbTests()) + { + return; + } + + using (var scratch = new ScratchDatabaseFixture()) + { + var services = new ServiceCollection().AddLogging(); + services.AddIdentity(options => + { + options.Stores.ProtectPersonalData = protect; + }) + .AddEntityFrameworkStores>() + .AddPersonalDataProtection(); + + var dbOptions = new DbContextOptionsBuilder().UseSqlServer(scratch.ConnectionString) + .UseApplicationServiceProvider(services.BuildServiceProvider()) + .Options; + var dbContext = new IdentityDbContext(dbOptions); + services.AddSingleton(dbContext); + dbContext.Database.EnsureCreated(); + + var sp = services.BuildServiceProvider(); + var manager = sp.GetService>(); + + var guid = Guid.NewGuid().ToString(); + var user = new CustomUser(); + user.Id = guid; + user.UserName = guid; + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + user.Email = "test@test.com"; + user.PersonalData1 = "p1"; + user.PersonalData2 = "p2"; + user.NonPersonalData1 = "np1"; + user.NonPersonalData2 = "np2"; + user.SafePersonalData = "safe"; + user.PhoneNumber = "12345678"; + IdentityResultAssert.IsSuccess(await manager.UpdateAsync(user)); + + var conn = dbContext.Database.GetDbConnection(); + conn.Open(); + if (protect) + { + Assert.True(FindInk(conn, "PhoneNumber", guid)); + Assert.True(FindInk(conn, "Email", guid)); + Assert.True(FindInk(conn, "UserName", guid)); + Assert.True(FindInk(conn, "PersonalData1", guid)); + Assert.True(FindInk(conn, "PersonalData2", guid)); + } + else + { + Assert.False(FindInk(conn, "PhoneNumber", guid)); + Assert.False(FindInk(conn, "Email", guid)); + Assert.False(FindInk(conn, "UserName", guid)); + Assert.False(FindInk(conn, "PersonalData1", guid)); + Assert.False(FindInk(conn, "PersonalData2", guid)); + + } + Assert.False(FindInk(conn, "NonPersonalData1", guid)); + Assert.False(FindInk(conn, "NonPersonalData2", guid)); + Assert.False(FindInk(conn, "SafePersonalData", guid)); + + conn.Close(); + } + } + + private class InvalidUser : IdentityUser + { + [ProtectedPersonalData] + public bool PersonalData1 { get; set; } + } + + /// + /// Test. + /// + [Fact] + public void ProtectedPersonalDataThrowsOnNonString() + { + if (ShouldSkipDbTests()) + { + return; + } + + using (var scratch = new ScratchDatabaseFixture()) + { + var services = new ServiceCollection().AddLogging(); + services.AddIdentity(options => + { + options.Stores.ProtectPersonalData = true; + }) + .AddEntityFrameworkStores>() + .AddPersonalDataProtection(); + var dbOptions = new DbContextOptionsBuilder().UseSqlServer(scratch.ConnectionString) + .UseApplicationServiceProvider(services.BuildServiceProvider()) + .Options; + var dbContext = new IdentityDbContext(dbOptions); + var e = Assert.Throws(() => dbContext.Database.EnsureCreated()); + Assert.Equal("[ProtectedPersonalData] only works strings by default.", e.Message); + } + } + + /// + /// Skipped because encryption causes this to fail. + /// + /// Task + [Fact] + public override Task CanFindUsersViaUserQuerable() + => Task.CompletedTask; + + } +} \ No newline at end of file diff --git a/test/EF.Test/UserStoreStringKeyTest.cs b/test/EF.Test/UserStoreStringKeyTest.cs index 499211381b..90e04e05a2 100644 --- a/test/EF.Test/UserStoreStringKeyTest.cs +++ b/test/EF.Test/UserStoreStringKeyTest.cs @@ -46,6 +46,5 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test // This used to throw var builder = services.AddIdentity, IdentityRole>().AddEntityFrameworkStores(); } - } } \ No newline at end of file diff --git a/test/Identity.FunctionalTests/Infrastructure/DefaultUIContext.cs b/test/Identity.FunctionalTests/Infrastructure/DefaultUIContext.cs index 30925f3806..2fb6047ded 100644 --- a/test/Identity.FunctionalTests/Infrastructure/DefaultUIContext.cs +++ b/test/Identity.FunctionalTests/Infrastructure/DefaultUIContext.cs @@ -12,6 +12,9 @@ namespace Microsoft.AspNetCore.Identity.FunctionalTests public DefaultUIContext WithAuthenticatedUser() => new DefaultUIContext(this) { UserAuthenticated = true }; + public DefaultUIContext WithAnonymousUser() => + new DefaultUIContext(this) { UserAuthenticated = false }; + public DefaultUIContext WithSocialLoginEnabled() => new DefaultUIContext(this) { ContosoLoginEnabled = true }; @@ -49,6 +52,7 @@ namespace Microsoft.AspNetCore.Identity.FunctionalTests get => GetValue(nameof(UserAuthenticated)); set => SetValue(nameof(UserAuthenticated), value); } + public bool ExistingUser { get => GetValue(nameof(ExistingUser)); diff --git a/test/Identity.FunctionalTests/ManagementTests.cs b/test/Identity.FunctionalTests/ManagementTests.cs index cdc12683b2..2bf1a8b32d 100644 --- a/test/Identity.FunctionalTests/ManagementTests.cs +++ b/test/Identity.FunctionalTests/ManagementTests.cs @@ -52,5 +52,42 @@ namespace Microsoft.AspNetCore.Identity.FunctionalTests var email = emails.SentEmails[1]; await UserStories.ConfirmEmailAsync(email, client); } + + [Fact] + public async Task CanDownloadPersonalData() + { + // Arrange + var client = ServerFactory.CreateDefaultClient(); + + var userName = $"{Guid.NewGuid()}@example.com"; + var password = $"!Test.Password1$"; + + var index = await UserStories.RegisterNewUserAsync(client, userName, password); + + // Act & Assert + var jsonData = await UserStories.DownloadPersonalData(index, userName); + Assert.Contains($"\"Id\":\"", jsonData); + Assert.Contains($"\"UserName\":\"{userName}\"", jsonData); + Assert.Contains($"\"Email\":\"{userName}\"", jsonData); + Assert.Contains($"\"EmailConfirmed\":\"False\"", jsonData); + Assert.Contains($"\"PhoneNumber\":\"null\"", jsonData); + Assert.Contains($"\"PhoneNumberConfirmed\":\"False\"", jsonData); + Assert.Contains($"\"TwoFactorEnabled\":\"False\"", jsonData); + } + + [Fact] + public async Task CanDeleteUser() + { + // Arrange + var client = ServerFactory.CreateDefaultClient(); + + var userName = $"{Guid.NewGuid()}@example.com"; + var password = $"!Test.Password1$"; + + var index = await UserStories.RegisterNewUserAsync(client, userName, password); + + // Act & Assert + await UserStories.DeleteUser(index, password); + } } } diff --git a/test/Identity.FunctionalTests/Pages/Account/Manage/DeleteUser.cs b/test/Identity.FunctionalTests/Pages/Account/Manage/DeleteUser.cs new file mode 100644 index 0000000000..0971b215f6 --- /dev/null +++ b/test/Identity.FunctionalTests/Pages/Account/Manage/DeleteUser.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using AngleSharp.Dom.Html; +using Xunit; + +namespace Microsoft.AspNetCore.Identity.FunctionalTests.Account.Manage +{ + public class DeleteUser : DefaultUIPage + { + private readonly IHtmlFormElement _deleteForm; + + public DeleteUser(HttpClient client, IHtmlDocument deleteUser, DefaultUIContext context) + : base(client, deleteUser, context) + { + _deleteForm = HtmlAssert.HasForm("#delete-user", deleteUser); + } + + public async Task Delete(string password) + { + var loggedIn = await SendDeleteForm(password); + + var deleteLocation = ResponseAssert.IsRedirect(loggedIn); + Assert.Equal(Index.Path, deleteLocation.ToString()); + var indexResponse = await Client.GetAsync(deleteLocation); + var index = await ResponseAssert.IsHtmlDocumentAsync(indexResponse); + return new FunctionalTests.Index(Client, index, Context); + } + + private async Task SendDeleteForm(string password) + { + return await Client.SendAsync(_deleteForm, new Dictionary() + { + ["Input_Password"] = password + }); + } + } +} \ No newline at end of file diff --git a/test/Identity.FunctionalTests/Pages/Account/Manage/Index.cs b/test/Identity.FunctionalTests/Pages/Account/Manage/Index.cs index 45fb58d684..329f3d4abe 100644 --- a/test/Identity.FunctionalTests/Pages/Account/Manage/Index.cs +++ b/test/Identity.FunctionalTests/Pages/Account/Manage/Index.cs @@ -17,6 +17,7 @@ namespace Microsoft.AspNetCore.Identity.FunctionalTests.Account.Manage private readonly IHtmlAnchorElement _personalDataLink; private readonly IHtmlFormElement _updateProfileForm; private readonly IHtmlElement _confirmEmailButton; + public static readonly string Path = "/"; public Index(HttpClient client, IHtmlDocument manage, DefaultUIContext context) : base(client, manage, context) @@ -53,5 +54,13 @@ namespace Microsoft.AspNetCore.Identity.FunctionalTests.Account.Manage return new Index(Client, manage, Context); } + + public async Task ClickPersonalDataLinkAsync() + { + var goToPersonalData = await Client.GetAsync(_personalDataLink.Href); + var personalData = await ResponseAssert.IsHtmlDocumentAsync(goToPersonalData); + return new PersonalData(Client, personalData, Context); + } + } } diff --git a/test/Identity.FunctionalTests/Pages/Account/Manage/PersonalData.cs b/test/Identity.FunctionalTests/Pages/Account/Manage/PersonalData.cs new file mode 100644 index 0000000000..c5c8fc8791 --- /dev/null +++ b/test/Identity.FunctionalTests/Pages/Account/Manage/PersonalData.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using AngleSharp.Dom.Html; + +namespace Microsoft.AspNetCore.Identity.FunctionalTests.Account.Manage +{ + public class PersonalData : DefaultUIPage + { + private readonly IHtmlAnchorElement _deleteLink; + private readonly IHtmlFormElement _downloadForm; + + public PersonalData(HttpClient client, IHtmlDocument personalData, DefaultUIContext context) + : base(client, personalData, context) + { + _deleteLink = HtmlAssert.HasLink("#delete", personalData); + _downloadForm = HtmlAssert.HasForm("#download-data", personalData); + } + + internal async Task ClickDeleteLinkAsync() + { + var goToDelete = await Client.GetAsync(_deleteLink.Href); + var delete = await ResponseAssert.IsHtmlDocumentAsync(goToDelete); + return new DeleteUser(Client, delete, Context.WithAnonymousUser()); + } + + internal async Task SubmitDownloadForm() + { + return await Client.SendAsync(_downloadForm, new Dictionary()); + } + } +} \ No newline at end of file diff --git a/test/Identity.FunctionalTests/UserStories.cs b/test/Identity.FunctionalTests/UserStories.cs index 824d4acefc..768efcd011 100644 --- a/test/Identity.FunctionalTests/UserStories.cs +++ b/test/Identity.FunctionalTests/UserStories.cs @@ -135,5 +135,21 @@ namespace Microsoft.AspNetCore.Identity.FunctionalTests var resetPassword = await ResetPassword.CreateAsync(link, client, new DefaultUIContext().WithExistingUser()); return await resetPassword.SendNewPasswordAsync(email, newPassword); } + + internal static async Task DeleteUser(Index index, string password) + { + var manage = await index.ClickManageLinkAsync(); + var personalData = await manage.ClickPersonalDataLinkAsync(); + var deleteUser = await personalData.ClickDeleteLinkAsync(); + return await deleteUser.Delete(password); + } + + internal static async Task DownloadPersonalData(Index index, string userName) + { + var manage = await index.ClickManageLinkAsync(); + var personalData = await manage.ClickPersonalDataLinkAsync(); + var download = await personalData.SubmitDownloadForm(); + return await download.Content.ReadAsStringAsync(); + } } } diff --git a/test/Identity.Test/UserManagerTest.cs b/test/Identity.Test/UserManagerTest.cs index 3f94f56008..19b7c283d0 100644 --- a/test/Identity.Test/UserManagerTest.cs +++ b/test/Identity.Test/UserManagerTest.cs @@ -698,6 +698,86 @@ namespace Microsoft.AspNetCore.Identity.Test Assert.ThrowsAsync(() => manager.GenerateUserTokenAsync(new TestUser(), "A", "purpose")); } + [Fact] + public void UserManagerThrowsIfStoreDoesNotSupportProtection() + { + var services = new ServiceCollection() + .AddLogging(); + services.AddIdentity(o => o.Stores.ProtectPersonalData = true) + .AddUserStore(); + var e = Assert.Throws(() => services.BuildServiceProvider().GetService>()); + Assert.Contains("Store does not implement IProtectedUserStore", e.Message); + } + + [Fact] + public void UserManagerThrowsIfMissingPersonalDataProtection() + { + var services = new ServiceCollection() + .AddLogging(); + services.AddIdentity(o => o.Stores.ProtectPersonalData = true) + .AddUserStore(); + var e = Assert.Throws(() => services.BuildServiceProvider().GetService>()); + Assert.Contains("No IPersonalDataProtector service was registered", e.Message); + } + + private class ProtectedStore : IProtectedUserStore + { + public Task CreateAsync(TestUser user, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task DeleteAsync(TestUser user, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public void Dispose() + { + throw new NotImplementedException(); + } + + public Task FindByIdAsync(string userId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetNormalizedUserNameAsync(TestUser user, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetUserIdAsync(TestUser user, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetUserNameAsync(TestUser user, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task SetNormalizedUserNameAsync(TestUser user, string normalizedName, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task SetUserNameAsync(TestUser user, string userName, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task UpdateAsync(TestUser user, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } + [Fact] public void UserManagerWillUseTokenProviderInstanceOverDefaults() {