V9: Fix for migration of non-default configurated users/members (#11684)

* https://github.com/umbraco/Umbraco-CMS/issues/11366
Fallback to try login using super legacy HMACSHA1 even when the algorithm is stated as being HMACSHA256. The issue is that v8 saves HMACSHA256 on the user, but when configured to use legacy encoding it actually uses HMACSHA1

* Support migration of members with:
UseLegacyEncoding+Clear
UseLegacyEncoding+Encrypted (Requires machine key)
UseLegacyEncoding+Hashed

* Fixes unit tests

* Avoid exceptions + unit tests

* Save unknown algorithm if we dont know it, instead of persisting a wrong algorithm.

* Added setting to enable clear text password rehashes.

* Removed support for migration of clear text passwords

* Fixed unit test
This commit is contained in:
Bjarke Berg
2021-12-02 11:54:24 +01:00
committed by GitHub
parent fce97314bd
commit 915f1cb34c
12 changed files with 224 additions and 65 deletions

View File

@@ -82,6 +82,7 @@ namespace JsonSchema
public BasicAuthSettings BasicAuth { get; set; }
public PackageMigrationSettings PackageMigration { get; set; }
public LegacyPasswordMigrationSettings LegacyPasswordMigration { get; set; }
}
/// <summary>

View File

@@ -0,0 +1,30 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models
{
/// <summary>
/// Typed configuration options for legacy machine key settings used for migration of members from a v8 solution.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigLegacyPasswordMigration)]
public class LegacyPasswordMigrationSettings
{
private const string StaticDecryptionKey = "";
/// <summary>
/// Gets the decryption algorithm.
/// </summary>
/// <remarks>
/// Currently only AES is supported. This should include all machine keys generated by Umbraco.
/// </remarks>
public string MachineKeyDecryption => "AES";
/// <summary>
/// Gets or sets the decryption hex-formatted string key found in legacy web.config machineKey configuration-element.
/// </summary>
[DefaultValue(StaticDecryptionKey)]
public string MachineKeyDecryptionKey { get; set; } = StaticDecryptionKey;
}
}

View File

@@ -26,6 +26,7 @@
public const string ConfigHostingDebug = ConfigHostingPrefix + "Debug";
public const string ConfigCustomErrorsMode = ConfigCustomErrorsPrefix + "Mode";
public const string ConfigActiveDirectory = ConfigPrefix + "ActiveDirectory";
public const string ConfigLegacyPasswordMigration = ConfigPrefix + "LegacyPasswordMigration";
public const string ConfigContent = ConfigPrefix + "Content";
public const string ConfigCoreDebug = ConfigCorePrefix + "Debug";
public const string ConfigExceptionFilter = ConfigPrefix + "ExceptionFilter";

View File

@@ -66,6 +66,7 @@ namespace Umbraco.Cms.Core
public const string AspNetCoreV2PasswordHashAlgorithmName = "PBKDF2.ASPNETCORE.V2";
public const string AspNetUmbraco8PasswordHashAlgorithmName = "HMACSHA256";
public const string AspNetUmbraco4PasswordHashAlgorithmName = "HMACSHA1";
public const string UnknownPasswordConfigJson = "{\"hashAlgorithm\":\"Unknown\"}";
}
}
}

View File

@@ -73,6 +73,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
.AddUmbracoOptions<RichTextEditorSettings>()
.AddUmbracoOptions<BasicAuthSettings>()
.AddUmbracoOptions<RuntimeMinificationSettings>()
.AddUmbracoOptions<LegacyPasswordMigrationSettings>()
.AddUmbracoOptions<PackageMigrationSettings>();
return builder;

View File

@@ -50,9 +50,33 @@ namespace Umbraco.Cms.Core.Security
if (dbPassword.StartsWith(Constants.Security.EmptyPasswordPrefix))
return false;
var storedHashedPass = ParseStoredHashPassword(algorithm, dbPassword, out var salt);
var hashed = HashPassword(algorithm, password, salt);
return storedHashedPass == hashed;
try
{
var storedHashedPass = ParseStoredHashPassword(algorithm, dbPassword, out var salt);
var hashed = HashPassword(algorithm, password, salt);
return storedHashedPass == hashed;
}
catch (ArgumentOutOfRangeException)
{
//This can happen if the length of the password is wrong and a salt cannot be extracted.
return false;
}
}
/// <summary>
/// Verify a legacy hashed password (HMACSHA1)
/// </summary>
public bool VerifyLegacyHashedPassword(string password, string dbPassword)
{
var hashAlgorith = new HMACSHA1
{
//the legacy salt was actually the password :(
Key = Encoding.Unicode.GetBytes(password)
};
var hashed = Convert.ToBase64String(hashAlgorith.ComputeHash(Encoding.Unicode.GetBytes(password)));
return dbPassword == hashed;
}
/// <summary>

View File

@@ -774,6 +774,9 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
{
memberDto.PasswordConfig = DefaultPasswordConfigJson;
changedCols.Add("passwordConfig");
}else if (memberDto.PasswordConfig == Constants.Security.UnknownPasswordConfigJson)
{
changedCols.Add("passwordConfig");
}
// do NOT update the password if it has not changed or if it is null or empty

View File

@@ -1,9 +1,15 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Identity;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Web.Common.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Security
@@ -16,9 +22,27 @@ namespace Umbraco.Cms.Core.Security
/// </remarks>
public class MemberPasswordHasher : UmbracoPasswordHasher<MemberIdentityUser>
{
private readonly IOptions<LegacyPasswordMigrationSettings> _legacyMachineKeySettings;
private readonly ILogger<MemberPasswordHasher> _logger;
[Obsolete("Use ctor with all params")]
public MemberPasswordHasher(LegacyPasswordSecurity legacyPasswordHasher, IJsonSerializer jsonSerializer)
: this(legacyPasswordHasher,
jsonSerializer,
StaticServiceProvider.Instance.GetRequiredService<IOptions<LegacyPasswordMigrationSettings>>(),
StaticServiceProvider.Instance.GetRequiredService<ILogger<MemberPasswordHasher>>())
{
}
public MemberPasswordHasher(
LegacyPasswordSecurity legacyPasswordHasher,
IJsonSerializer jsonSerializer,
IOptions<LegacyPasswordMigrationSettings> legacyMachineKeySettings,
ILogger<MemberPasswordHasher> logger)
: base(legacyPasswordHasher, jsonSerializer)
{
_legacyMachineKeySettings = legacyMachineKeySettings;
_logger = logger;
}
/// <summary>
@@ -36,10 +60,21 @@ namespace Umbraco.Cms.Core.Security
throw new ArgumentNullException(nameof(user));
}
var isPasswordAlgorithmKnown = user.PasswordConfig.IsNullOrWhiteSpace() == false &&
user.PasswordConfig != Constants.Security.UnknownPasswordConfigJson;
// if there's password config use the base implementation
if (!user.PasswordConfig.IsNullOrWhiteSpace())
if (isPasswordAlgorithmKnown)
{
return base.VerifyHashedPassword(user, hashedPassword, providedPassword);
var result = base.VerifyHashedPassword(user, hashedPassword, providedPassword);
if (result != PasswordVerificationResult.Failed)
{
return result;
}
}
// We need to check for clear text passwords from members as the first thing. This was possible in v8 :(
else if (IsSuccessfulLegacyPassword(hashedPassword, providedPassword))
{
return PasswordVerificationResult.SuccessRehashNeeded;
}
// Else we need to detect what the password is. This will be the case
@@ -66,7 +101,16 @@ namespace Umbraco.Cms.Core.Security
return base.VerifyHashedPassword(user, hashedPassword, providedPassword);
}
throw new InvalidOperationException("unable to determine member password hashing algorith");
if (isPasswordAlgorithmKnown)
{
_logger.LogError("Unable to determine member password hashing algorithm");
}
else
{
_logger.LogDebug("Unable to determine member password hashing algorithm, but this can happen when member enters a wrong password, before it has be rehashed");
}
return PasswordVerificationResult.Failed;
}
var isValid = LegacyPasswordSecurity.VerifyPassword(
@@ -76,5 +120,65 @@ namespace Umbraco.Cms.Core.Security
return isValid ? PasswordVerificationResult.SuccessRehashNeeded : PasswordVerificationResult.Failed;
}
private bool IsSuccessfulLegacyPassword(string hashedPassword, string providedPassword)
{
if (!string.IsNullOrEmpty(_legacyMachineKeySettings.Value.MachineKeyDecryptionKey))
{
try
{
var decryptedPassword = DecryptLegacyPassword(hashedPassword, _legacyMachineKeySettings.Value.MachineKeyDecryption, _legacyMachineKeySettings.Value.MachineKeyDecryptionKey);
return decryptedPassword == providedPassword;
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not decrypt password even that a DecryptionKey is provided. This means the DecryptionKey is wrong.");
return false;
}
}
var result = LegacyPasswordSecurity.VerifyPassword(Constants.Security.AspNetUmbraco8PasswordHashAlgorithmName, providedPassword, hashedPassword);
return result || LegacyPasswordSecurity.VerifyPassword(Constants.Security.AspNetUmbraco4PasswordHashAlgorithmName, providedPassword, hashedPassword);
}
private static string DecryptLegacyPassword(string encryptedPassword, string algorithmName, string decryptionKey)
{
SymmetricAlgorithm algorithm;
switch (algorithmName)
{
case "AES":
algorithm = new AesCryptoServiceProvider()
{
Key = StringToByteArray(decryptionKey),
IV = new byte[16]
};
break;
default:
throw new NotSupportedException($"The algorithm ({algorithmName}) is not supported");
}
using (algorithm)
{
return DecryptLegacyPassword(encryptedPassword, algorithm);
}
}
private static string DecryptLegacyPassword(string encryptedPassword, SymmetricAlgorithm algorithm)
{
using var memoryStream = new MemoryStream();
ICryptoTransform cryptoTransform = algorithm.CreateDecryptor();
var cryptoStream = new CryptoStream((Stream)memoryStream, cryptoTransform, CryptoStreamMode.Write);
var buf = Convert.FromBase64String(encryptedPassword);
cryptoStream.Write(buf, 0, 32);
cryptoStream.FlushFinalBlock();
return Encoding.Unicode.GetString(memoryStream.ToArray());
}
private static byte[] StringToByteArray(string hex) =>
Enumerable.Range(0, hex.Length)
.Where(x => x % 2 == 0)
.Select(x => Convert.ToByte(hex.Substring(x, 2), 16))
.ToArray();
}
}

View File

@@ -592,6 +592,12 @@ namespace Umbraco.Cms.Core.Security
member.PasswordConfiguration = identityUser.PasswordConfig;
}
if (member.PasswordConfiguration != identityUser.PasswordConfig)
{
changeType = MemberDataChangeType.FullSave;
member.PasswordConfiguration = identityUser.PasswordConfig;
}
if (member.SecurityStamp != identityUser.SecurityStamp)
{
changeType = MemberDataChangeType.FullSave;

View File

@@ -53,6 +53,13 @@ namespace Umbraco.Cms.Core.Security
if (LegacyPasswordSecurity.SupportHashAlgorithm(deserialized.HashAlgorithm))
{
var result = LegacyPasswordSecurity.VerifyPassword(deserialized.HashAlgorithm, providedPassword, hashedPassword);
//We need to special handle this case, apparently v8 still saves the user algorithm as {"hashAlgorithm":"HMACSHA256"}, when using legacy encoding and hasinging.
if (result == false)
{
result = LegacyPasswordSecurity.VerifyLegacyHashedPassword(providedPassword, hashedPassword);
}
return result
? PasswordVerificationResult.SuccessRehashNeeded
: PasswordVerificationResult.Failed;

View File

@@ -96,7 +96,7 @@ namespace Umbraco.Cms.Core.Security
string password = _passwordGenerator.GeneratePassword();
return password;
}
/// <summary>
/// Used to validate the password without an identity user
/// Validation code is based on the default ValidatePasswordAsync code
@@ -205,6 +205,8 @@ namespace Umbraco.Cms.Core.Security
await lockoutStore.ResetAccessFailedCountAsync(user, CancellationToken.None);
//Ensure the password config is null, so it is set to the default in repository
user.PasswordConfig = null;
return await UpdateAsync(user);
}
@@ -234,6 +236,11 @@ namespace Umbraco.Cms.Core.Security
// here we are persisting the value for the back office
}
if (string.IsNullOrEmpty(user.PasswordConfig))
{
//We cant pass null as that would be interpreted as the default algoritm, but due to the failing attempt we dont know.
user.PasswordConfig = Constants.Security.UnknownPasswordConfigJson;
}
IdentityResult result = await UpdateAsync(user);
return result;
}

View File

@@ -1,6 +1,10 @@
using System;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Infrastructure.Security;
using Umbraco.Cms.Infrastructure.Serialization;
@@ -10,65 +14,35 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Security
[TestFixture]
public class MemberPasswordHasherTests
{
private MemberPasswordHasher CreateSut() => new MemberPasswordHasher(new LegacyPasswordSecurity(), new JsonNetSerializer());
[Test]
public void VerifyHashedPassword_GivenAnAspNetIdentity2PasswordHash_ThenExpectSuccessRehashNeeded()
[TestCase("Password123!", "AQAAAAEAACcQAAAAEGF/tTVoL6ef3bQPZFYfbgKFu1CDQIAMgyY1N4EDt9jqdG/hsOX93X1U6LNvlIQ3mw==", null, ExpectedResult = PasswordVerificationResult.Success, Description = "AspNetCoreIdentityPasswordHash: Correct password")]
[TestCase("wrongPassword", "AQAAAAEAACcQAAAAEGF/tTVoL6ef3bQPZFYfbgKFu1CDQIAMgyY1N4EDt9jqdG/hsOX93X1U6LNvlIQ3mw==", null, ExpectedResult = PasswordVerificationResult.Failed, Description = "AspNetCoreIdentityPasswordHash: Wrong password")]
[TestCase("Password123!", "yDiU2YyuYZU4jz6F0fpErQ==BxNRHkXBVyJs9gwWF6ktWdfDwYf5bwm+rvV7tOcNNx8=", null, ExpectedResult = PasswordVerificationResult.SuccessRehashNeeded, Description = "GivenALegacyPasswordHash: Correct password")]
[TestCase("wrongPassword", "yDiU2YyuYZU4jz6F0fpErQ==BxNRHkXBVyJs9gwWF6ktWdfDwYf5bwm+rvV7tOcNNx8=", null, ExpectedResult = PasswordVerificationResult.Failed, Description = "GivenALegacyPasswordHash: Wrong password")]
[TestCase("Password123!", "AJszAsQqxOYbASKfL3JVUu6cjU18ouizXDfX4j7wLlir8SWj2yQaTepE9e5bIohIsQ==", null, ExpectedResult = PasswordVerificationResult.SuccessRehashNeeded, Description = "GivenALegacyPasswordHash: Correct password")]
[TestCase("wrongPassword", "AJszAsQqxOYbASKfL3JVUu6cjU18ouizXDfX4j7wLlir8SWj2yQaTepE9e5bIohIsQ==", null, ExpectedResult = PasswordVerificationResult.Failed, Description = "GivenALegacyPasswordHash: Wrong password")]
[TestCase("1234567890", "1234567890", null, ExpectedResult = PasswordVerificationResult.Failed, Description = "ClearText: Correct password, but not supported")]
[TestCase("wrongPassword", "1234567890", null, ExpectedResult = PasswordVerificationResult.Failed, Description = "ClearText: Wrong password")]
[TestCase("1234567890", "XyFRG4/xJ5JGQJYqqIFK70BjHdM=", null, ExpectedResult = PasswordVerificationResult.SuccessRehashNeeded, Description = "Hashed: Correct password")]
[TestCase("wrongPassword", "XyFRG4/xJ5JGQJYqqIFK70BjHdM=", null, ExpectedResult = PasswordVerificationResult.Failed, Description = "Hashed: Wrong password")]
[TestCase("1234567890", "K2JPOhoqNoysfnnD67QsWDSliHrjoSTRTvv9yiaKf30=", "1D43BFA074DF6DCEF6E44A7F5B5F56CDDD60BE198FBBB0222C96A5BD696F3CAA", ExpectedResult = PasswordVerificationResult.SuccessRehashNeeded, Description = "Encrypted: Correct password and correct decryptionKey")]
[TestCase("wrongPassword", "K2JPOhoqNoysfnnD67QsWDSliHrjoSTRTvv9yiaKf30=", "1D43BFA074DF6DCEF6E44A7F5B5F56CDDD60BE198FBBB0222C96A5BD696F3CAA", ExpectedResult = PasswordVerificationResult.Failed, Description = "Encrypted: Wrong password but correct decryptionKey")]
[TestCase("1234567890", "qiuwRr4K7brpTcIzLFfR3iGG9zj4/z4ewHCVZmYUDKM=", "B491B602E0CE1D52450A8089FD2013B340743A7EFCC12B039BD11977A083ACA1", ExpectedResult = PasswordVerificationResult.Failed, Description = "Encrypted: Correct password but wrong decryptionKey")]
[TestCase("1234567890", "qiuwRr4K7brpTcIzLFfR3iGG9zj4/z4ewHCVZmYUDKM=", "InvalidDecryptionKey", ExpectedResult = PasswordVerificationResult.Failed, Description = "Encrypted: Invalid decryptionKey")]
public PasswordVerificationResult VerifyHashedPassword(string password, string encryptedPassword, string decryptionKey)
{
const string password = "Password123!";
const string hash = "AJszAsQqxOYbASKfL3JVUu6cjU18ouizXDfX4j7wLlir8SWj2yQaTepE9e5bIohIsQ==";
var member = new MemberIdentityUser() { PasswordConfig = null };
var sut = CreateSut();
var result = sut.VerifyHashedPassword(new MemberIdentityUser(), hash, password);
var sut = new MemberPasswordHasher(
new LegacyPasswordSecurity(),
new JsonNetSerializer(),
Options.Create<LegacyPasswordMigrationSettings>(new LegacyPasswordMigrationSettings()
{
MachineKeyDecryptionKey = decryptionKey
}),
NullLoggerFactory.Instance.CreateLogger<MemberPasswordHasher>());
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(new MemberIdentityUser(), 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(new MemberIdentityUser(), 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(new MemberIdentityUser(), 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(new MemberIdentityUser(), hash, invalidPassword);
Assert.AreEqual(result, PasswordVerificationResult.Failed);
return sut.VerifyHashedPassword(member, encryptedPassword, password);
}
}
}