Member password roll forward (#10138)

* Getting new netcore PublicAccessChecker in place

* Adds full test coverage for PublicAccessChecker

* remove PublicAccessComposer

* adjust namespaces, ensure RoleManager works, separate public access controller, reduce content controller

* Implements the required methods on IMemberManager, removes old migrated code

* Updates routing to be able to re-route, Fixes middleware ordering ensuring endpoints are last, refactors pipeline options, adds public access middleware, ensures public access follows all hops

* adds note

* adds note

* Cleans up ext methods, ensures that members identity is added on both front-end and back ends. updates how UmbracoApplicationBuilder works in that it explicitly starts endpoints at the time of calling.

* Changes name to IUmbracoEndpointBuilder

* adds note

* Fixing tests, fixing error describers so there's 2x one for back office, one for members, fixes TryConvertTo, fixes login redirect

* fixing build

* Updates user manager to correctly validate password hashing and injects the IBackOfficeUserPasswordChecker

* Merges PR

* Fixes up build and notes

* Fixes keepalive, fixes PublicAccessMiddleware to not throw, updates startup code to be more clear and removes magic that registers middleware.

* adds note

* removes unused filter, fixes build

* fixes WebPath and tests

* Looks up entities in one query

* remove usings

* Fix test, remove stylesheet

* Set status code before we write to response to avoid error

* Ensures that users and members are validated when logging in. Shares more code between users and members.

* Fixes RepositoryCacheKeys to ensure the keys are normalized

* oops didn't mean to commit this

* Fix casing issues with caching, stop boxing value types for all cache operations, stop re-creating string keys in DefaultRepositoryCachePolicy

* oops didn't mean to comit this

* bah, far out this keeps getting recommitted. sorry

Co-authored-by: Bjarke Berg <mail@bergmania.dk>
This commit is contained in:
Shannon Deminick
2021-04-20 15:45:35 +10:00
committed by GitHub
parent a1624d26a3
commit de28fbb0a4
7 changed files with 191 additions and 42 deletions

View File

@@ -29,19 +29,19 @@ namespace Umbraco.Cms.Core.Security
{
// We need to manually register some identity services here because we cannot rely on normal
// AddIdentity calls for back office users
// For example: https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Extensions.Core/src/IdentityServiceCollectionExtensions.cs#L33
// For example: https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Extensions.Core/src/IdentityServiceCollectionExtensions.cs#L33
// The reason we need our own is because the Identity system doesn't cater easily for multiple identity systems and particularly being
// able to configure IdentityOptions to a specific provider since there is no named options. So we have strongly typed options
// and strongly typed ILookupNormalizer and IdentityErrorDescriber since those are 'global' and we need to be unintrusive.
// Services used by identity
services.TryAddScoped<IUserValidator<BackOfficeIdentityUser>, UserValidator<BackOfficeIdentityUser>>();
services.TryAddScoped<IPasswordValidator<BackOfficeIdentityUser>, PasswordValidator<BackOfficeIdentityUser>>();
services.TryAddScoped<IPasswordHasher<BackOfficeIdentityUser>>(
services.AddScoped<IUserValidator<BackOfficeIdentityUser>, UserValidator<BackOfficeIdentityUser>>();
services.AddScoped<IPasswordValidator<BackOfficeIdentityUser>, PasswordValidator<BackOfficeIdentityUser>>();
services.AddScoped<IPasswordHasher<BackOfficeIdentityUser>>(
services => new BackOfficePasswordHasher(
new LegacyPasswordSecurity(),
services.GetRequiredService<IJsonSerializer>()));
services.TryAddScoped<IUserConfirmation<BackOfficeIdentityUser>, UmbracoUserConfirmation<BackOfficeIdentityUser>>();
services.AddScoped<IUserConfirmation<BackOfficeIdentityUser>, UmbracoUserConfirmation<BackOfficeIdentityUser>>();
}
// override to add itself, by default identity only wants a single IdentityErrorDescriber

View File

@@ -0,0 +1,62 @@
using System;
using Microsoft.AspNetCore.Identity;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Core.Security
{
/// <summary>
/// A password hasher for members
/// </summary>
/// <remarks>
/// This will check for the ASP.NET Identity password hash flag before falling back to the legacy password hashing format ("HMACSHA256")
/// </remarks>
public class MemberPasswordHasher : PasswordHasher<MemberIdentityUser>
{
private readonly LegacyPasswordSecurity _legacyPasswordHasher;
public MemberPasswordHasher(LegacyPasswordSecurity legacyPasswordHasher) => _legacyPasswordHasher = legacyPasswordHasher ?? throw new ArgumentNullException(nameof(legacyPasswordHasher));
/// <summary>
/// Verifies a user's hashed password
/// </summary>
/// <param name="user"></param>
/// <param name="hashedPassword"></param>
/// <param name="providedPassword"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException">Thrown when the correct hashing algorith cannot be determined</exception>
public override PasswordVerificationResult VerifyHashedPassword(MemberIdentityUser user, string hashedPassword, string providedPassword)
{
byte[] decodedHashedPassword = null;
bool isAspNetIdentityHash = false;
try
{
decodedHashedPassword = Convert.FromBase64String(hashedPassword);
isAspNetIdentityHash = true;
}
catch (Exception)
{
// ignored - decoding throws
}
// check for default ASP.NET Identity password hash flags
if (isAspNetIdentityHash)
{
if (decodedHashedPassword[0] == 0x00 || decodedHashedPassword[0] == 0x01)
{
return base.VerifyHashedPassword(user, hashedPassword, providedPassword);
}
throw new InvalidOperationException("unable to determine member password hashing algorith");
}
var isValid = _legacyPasswordHasher.VerifyPassword(
Constants.Security.AspNetUmbraco8PasswordHashAlgorithmName,
providedPassword,
hashedPassword);
return isValid ? PasswordVerificationResult.SuccessRehashNeeded : PasswordVerificationResult.Failed;
}
}
}

View File

@@ -0,0 +1,73 @@
using System;
using Microsoft.AspNetCore.Identity;
using NUnit.Framework;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Infrastructure.Security;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Security
{
[TestFixture]
public class MemberPasswordHasherTests
{
private MemberPasswordHasher CreateSut() => new MemberPasswordHasher(new LegacyPasswordSecurity());
[Test]
public void VerifyHashedPassword_GivenAnAspNetIdentity2PasswordHash_ThenExpectSuccessRehashNeeded()
{
const string password = "Password123!";
const string hash = "AJszAsQqxOYbASKfL3JVUu6cjU18ouizXDfX4j7wLlir8SWj2yQaTepE9e5bIohIsQ==";
var sut = CreateSut();
var result = sut.VerifyHashedPassword(null, hash, password);
Assert.AreEqual(result, PasswordVerificationResult.SuccessRehashNeeded);
}
[Test]
public void VerifyHashedPassword_GivenAnAspNetCoreIdentityPasswordHash_ThenExpectSuccess()
{
const string password = "Password123!";
const string hash = "AQAAAAEAACcQAAAAEGF/tTVoL6ef3bQPZFYfbgKFu1CDQIAMgyY1N4EDt9jqdG/hsOX93X1U6LNvlIQ3mw==";
var sut = CreateSut();
var result = sut.VerifyHashedPassword(null, hash, password);
Assert.AreEqual(result, PasswordVerificationResult.Success);
}
[Test]
public void VerifyHashedPassword_GivenALegacyPasswordHash_ThenExpectSuccessRehashNeeded()
{
const string password = "Password123!";
const string hash = "yDiU2YyuYZU4jz6F0fpErQ==BxNRHkXBVyJs9gwWF6ktWdfDwYf5bwm+rvV7tOcNNx8=";
var sut = CreateSut();
var result = sut.VerifyHashedPassword(null, hash, password);
Assert.AreEqual(result, PasswordVerificationResult.SuccessRehashNeeded);
}
[Test]
public void VerifyHashedPassword_GivenAnUnknownBase64Hash_ThenExpectInvalidOperationException()
{
var hashBytes = new byte[] {3, 2, 1};
var hash = Convert.ToBase64String(hashBytes);
var sut = CreateSut();
Assert.Throws<InvalidOperationException>(() => sut.VerifyHashedPassword(null, hash, "password"));
}
[TestCase("AJszAsQqxOYbASKfL3JVUu6cjU18ouizXDfX4j7wLlir8SWj2yQaTepE9e5bIohIsQ==")]
[TestCase("AQAAAAEAACcQAAAAEGF/tTVoL6ef3bQPZFYfbgKFu1CDQIAMgyY1N4EDt9jqdG/hsOX93X1U6LNvlIQ3mw==")]
[TestCase("yDiU2YyuYZU4jz6F0fpErQ==BxNRHkXBVyJs9gwWF6ktWdfDwYf5bwm+rvV7tOcNNx8=")]
public void VerifyHashedPassword_GivenAnInvalidPassword_ThenExpectFailure(string hash)
{
const string invalidPassword = "nope";
var sut = CreateSut();
var result = sut.VerifyHashedPassword(null, hash, invalidPassword);
Assert.AreEqual(result, PasswordVerificationResult.Failed);
}
}
}

View File

@@ -33,6 +33,8 @@ namespace Umbraco.Extensions
.AddClaimsPrincipalFactory<BackOfficeClaimsPrincipalFactory>()
.AddErrorDescriber<BackOfficeErrorDescriber>();
services.TryAddSingleton<IBackOfficeUserPasswordChecker, NoopBackOfficeUserPasswordChecker>();
// Configure the options specifically for the UmbracoBackOfficeIdentityOptions instance
services.ConfigureOptions<ConfigureBackOfficeIdentityOptions>();
services.ConfigureOptions<ConfigureBackOfficeSecurityStampValidatorOptions>();

View File

@@ -25,6 +25,14 @@ namespace Umbraco.Extensions
return builder;
}
// NOTE: We are using AddIdentity which is going to add all of the default AuthN/AuthZ configurations = OK!
// This will also add all of the default identity services for our user/role types that we aren't overriding = OK!
// If a developer wishes to use Umbraco Members with different AuthN/AuthZ values, like different cookie values
// or authentication scheme's then they can call the default identity configuration methods like ConfigureApplicationCookie.
// BUT ... if a developer wishes to use the default auth schemes for entirely separate purposes alongside Umbraco members,
// then we'll probably have to change this and make it more flexible like how we do for Users. Which means booting up
// identity here with the basics and registering all of our own custom services.
// Since we are using the defaults in v8 (and below) for members, I think using the default for members now is OK!
// TODO: We may need to use services.AddIdentityCore instead if this is doing too much
services.AddIdentity<MemberIdentityUser, UmbracoIdentityRole>()
@@ -39,6 +47,8 @@ namespace Umbraco.Extensions
services.ConfigureOptions<ConfigureMemberIdentityOptions>();
services.AddScoped<IPasswordHasher<MemberIdentityUser>, MemberPasswordHasher>();
services.ConfigureApplicationCookie(x =>
{
// TODO: We may want/need to configure these further

View File

@@ -20,6 +20,7 @@ namespace Umbraco.Cms.Web.Common.Security
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IEventAggregator _eventAggregator;
private readonly IBackOfficeUserPasswordChecker _backOfficeUserPasswordChecker;
public BackOfficeUserManager(
IIpResolver ipResolver,
@@ -33,53 +34,43 @@ namespace Umbraco.Cms.Web.Common.Security
IHttpContextAccessor httpContextAccessor,
ILogger<UserManager<BackOfficeIdentityUser>> logger,
IOptions<UserPasswordConfigurationSettings> passwordConfiguration,
IEventAggregator eventAggregator)
IEventAggregator eventAggregator,
IBackOfficeUserPasswordChecker backOfficeUserPasswordChecker)
: base(ipResolver, store, optionsAccessor, passwordHasher, userValidators, passwordValidators, errors, services, logger, passwordConfiguration)
{
_httpContextAccessor = httpContextAccessor;
_eventAggregator = eventAggregator;
_backOfficeUserPasswordChecker = backOfficeUserPasswordChecker;
}
/// <summary>
/// Gets or sets the default back office user password checker
/// Override to allow checking the password via the <see cref="IBackOfficeUserPasswordChecker"/> if one is configured
/// </summary>
public IBackOfficeUserPasswordChecker BackOfficeUserPasswordChecker { get; set; } // TODO: This isn't a good way to set this, it needs to be injected
/// <inheritdoc />
/// <remarks>
/// By default this uses the standard ASP.Net Identity approach which is:
/// * Get password store
/// * Call VerifyPasswordAsync with the password store + user + password
/// * Uses the PasswordHasher.VerifyHashedPassword to compare the stored password
///
/// In some cases people want simple custom control over the username/password check, for simplicity
/// sake, developers would like the users to simply validate against an LDAP directory but the user
/// data remains stored inside of Umbraco.
/// See: http://issues.umbraco.org/issue/U4-7032 for the use cases.
///
/// We've allowed this check to be overridden with a simple callback so that developers don't actually
/// have to implement/override this class.
/// </remarks>
public override async Task<bool> CheckPasswordAsync(BackOfficeIdentityUser user, string password)
/// <param name="store"></param>
/// <param name="user"></param>
/// <param name="password"></param>
/// <returns></returns>
protected override async Task<PasswordVerificationResult> VerifyPasswordAsync(
IUserPasswordStore<BackOfficeIdentityUser> store,
BackOfficeIdentityUser user,
string password)
{
if (BackOfficeUserPasswordChecker != null)
if (user.HasIdentity == false)
{
BackOfficeUserPasswordCheckerResult result = await BackOfficeUserPasswordChecker.CheckPasswordAsync(user, password);
if (user.HasIdentity == false)
{
return false;
}
// if the result indicates to not fallback to the default, then return true if the credentials are valid
if (result != BackOfficeUserPasswordCheckerResult.FallbackToDefaultChecker)
{
return result == BackOfficeUserPasswordCheckerResult.ValidCredentials;
}
return PasswordVerificationResult.Failed;
}
// use the default behavior
return await base.CheckPasswordAsync(user, password);
BackOfficeUserPasswordCheckerResult result = await _backOfficeUserPasswordChecker.CheckPasswordAsync(user, password);
// if the result indicates to not fallback to the default, then return true if the credentials are valid
if (result != BackOfficeUserPasswordCheckerResult.FallbackToDefaultChecker)
{
return result == BackOfficeUserPasswordCheckerResult.ValidCredentials
? PasswordVerificationResult.Success
: PasswordVerificationResult.Failed;
}
return await base.VerifyPasswordAsync(store, user, password);
}
/// <summary>
@@ -139,7 +130,7 @@ namespace Umbraco.Cms.Web.Common.Security
return result;
}
/// <inheritdoc/>
public override async Task<IdentityResult> SetLockoutEndDateAsync(BackOfficeIdentityUser user, DateTimeOffset? lockoutEnd)
{
@@ -218,7 +209,7 @@ namespace Umbraco.Cms.Web.Common.Security
(currentUserId, ip) => new UserLogoutSuccessNotification(ip, userId, currentUserId)
);
return new SignOutSuccessResult {SignOutRedirectUrl = notification.SignOutRedirectUrl};
return new SignOutSuccessResult { SignOutRedirectUrl = notification.SignOutRedirectUrl };
}
public void NotifyPasswordChanged(IPrincipal currentUser, string userId) => Notify(currentUser,

View File

@@ -0,0 +1,11 @@
using System.Threading.Tasks;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Web.Common.Security
{
public class NoopBackOfficeUserPasswordChecker : IBackOfficeUserPasswordChecker
{
public Task<BackOfficeUserPasswordCheckerResult> CheckPasswordAsync(BackOfficeIdentityUser user, string password)
=> Task.FromResult(BackOfficeUserPasswordCheckerResult.FallbackToDefaultChecker);
}
}