Skip to content

Commit 915f1cb

Browse files
authored
V9: Fix for migration of non-default configurated users/members (#11684)
* #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
1 parent fce9731 commit 915f1cb

File tree

12 files changed

+226
-67
lines changed

12 files changed

+226
-67
lines changed

src/JsonSchema/AppSettings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ public class CmsDefinition
8282
public BasicAuthSettings BasicAuth { get; set; }
8383

8484
public PackageMigrationSettings PackageMigration { get; set; }
85+
public LegacyPasswordMigrationSettings LegacyPasswordMigration { get; set; }
8586
}
8687

8788
/// <summary>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) Umbraco.
2+
// See LICENSE for more details.
3+
4+
using System.ComponentModel;
5+
6+
namespace Umbraco.Cms.Core.Configuration.Models
7+
{
8+
/// <summary>
9+
/// Typed configuration options for legacy machine key settings used for migration of members from a v8 solution.
10+
/// </summary>
11+
[UmbracoOptions(Constants.Configuration.ConfigLegacyPasswordMigration)]
12+
public class LegacyPasswordMigrationSettings
13+
{
14+
private const string StaticDecryptionKey = "";
15+
16+
/// <summary>
17+
/// Gets the decryption algorithm.
18+
/// </summary>
19+
/// <remarks>
20+
/// Currently only AES is supported. This should include all machine keys generated by Umbraco.
21+
/// </remarks>
22+
public string MachineKeyDecryption => "AES";
23+
24+
/// <summary>
25+
/// Gets or sets the decryption hex-formatted string key found in legacy web.config machineKey configuration-element.
26+
/// </summary>
27+
[DefaultValue(StaticDecryptionKey)]
28+
public string MachineKeyDecryptionKey { get; set; } = StaticDecryptionKey;
29+
}
30+
}

src/Umbraco.Core/Constants-Configuration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public static class Configuration
2626
public const string ConfigHostingDebug = ConfigHostingPrefix + "Debug";
2727
public const string ConfigCustomErrorsMode = ConfigCustomErrorsPrefix + "Mode";
2828
public const string ConfigActiveDirectory = ConfigPrefix + "ActiveDirectory";
29+
public const string ConfigLegacyPasswordMigration = ConfigPrefix + "LegacyPasswordMigration";
2930
public const string ConfigContent = ConfigPrefix + "Content";
3031
public const string ConfigCoreDebug = ConfigCorePrefix + "Debug";
3132
public const string ConfigExceptionFilter = ConfigPrefix + "ExceptionFilter";

src/Umbraco.Core/Constants-Security.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public static class Security
6666
public const string AspNetCoreV2PasswordHashAlgorithmName = "PBKDF2.ASPNETCORE.V2";
6767
public const string AspNetUmbraco8PasswordHashAlgorithmName = "HMACSHA256";
6868
public const string AspNetUmbraco4PasswordHashAlgorithmName = "HMACSHA1";
69+
public const string UnknownPasswordConfigJson = "{\"hashAlgorithm\":\"Unknown\"}";
6970
}
7071
}
7172
}

src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public static IUmbracoBuilder AddConfiguration(this IUmbracoBuilder builder)
7373
.AddUmbracoOptions<RichTextEditorSettings>()
7474
.AddUmbracoOptions<BasicAuthSettings>()
7575
.AddUmbracoOptions<RuntimeMinificationSettings>()
76+
.AddUmbracoOptions<LegacyPasswordMigrationSettings>()
7677
.AddUmbracoOptions<PackageMigrationSettings>();
7778

7879
return builder;

src/Umbraco.Core/Security/LegacyPasswordSecurity.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,33 @@ public bool VerifyPassword(string algorithm, string password, string dbPassword)
5050
if (dbPassword.StartsWith(Constants.Security.EmptyPasswordPrefix))
5151
return false;
5252

53-
var storedHashedPass = ParseStoredHashPassword(algorithm, dbPassword, out var salt);
54-
var hashed = HashPassword(algorithm, password, salt);
55-
return storedHashedPass == hashed;
53+
try
54+
{
55+
var storedHashedPass = ParseStoredHashPassword(algorithm, dbPassword, out var salt);
56+
var hashed = HashPassword(algorithm, password, salt);
57+
return storedHashedPass == hashed;
58+
}
59+
catch (ArgumentOutOfRangeException)
60+
{
61+
//This can happen if the length of the password is wrong and a salt cannot be extracted.
62+
return false;
63+
}
64+
65+
}
66+
67+
/// <summary>
68+
/// Verify a legacy hashed password (HMACSHA1)
69+
/// </summary>
70+
public bool VerifyLegacyHashedPassword(string password, string dbPassword)
71+
{
72+
var hashAlgorith = new HMACSHA1
73+
{
74+
//the legacy salt was actually the password :(
75+
Key = Encoding.Unicode.GetBytes(password)
76+
};
77+
var hashed = Convert.ToBase64String(hashAlgorith.ComputeHash(Encoding.Unicode.GetBytes(password)));
78+
79+
return dbPassword == hashed;
5680
}
5781

5882
/// <summary>

src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,9 @@ protected override void PersistUpdatedItem(IMember entity)
774774
{
775775
memberDto.PasswordConfig = DefaultPasswordConfigJson;
776776
changedCols.Add("passwordConfig");
777+
}else if (memberDto.PasswordConfig == Constants.Security.UnknownPasswordConfigJson)
778+
{
779+
changedCols.Add("passwordConfig");
777780
}
778781

779782
// do NOT update the password if it has not changed or if it is null or empty

src/Umbraco.Infrastructure/Security/MemberPasswordHasher.cs

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
using System;
2+
using System.IO;
3+
using System.Linq;
4+
using System.Security.Cryptography;
5+
using System.Text;
26
using Microsoft.AspNetCore.Identity;
3-
using Umbraco.Cms.Core;
4-
using Umbraco.Cms.Core.Models.Membership;
5-
using Umbraco.Cms.Core.Security;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Logging;
9+
using Microsoft.Extensions.Options;
10+
using Umbraco.Cms.Core.Configuration.Models;
611
using Umbraco.Cms.Core.Serialization;
12+
using Umbraco.Cms.Web.Common.DependencyInjection;
713
using Umbraco.Extensions;
814

915
namespace Umbraco.Cms.Core.Security
@@ -16,9 +22,27 @@ namespace Umbraco.Cms.Core.Security
1622
/// </remarks>
1723
public class MemberPasswordHasher : UmbracoPasswordHasher<MemberIdentityUser>
1824
{
25+
private readonly IOptions<LegacyPasswordMigrationSettings> _legacyMachineKeySettings;
26+
private readonly ILogger<MemberPasswordHasher> _logger;
27+
28+
[Obsolete("Use ctor with all params")]
1929
public MemberPasswordHasher(LegacyPasswordSecurity legacyPasswordHasher, IJsonSerializer jsonSerializer)
30+
: this(legacyPasswordHasher,
31+
jsonSerializer,
32+
StaticServiceProvider.Instance.GetRequiredService<IOptions<LegacyPasswordMigrationSettings>>(),
33+
StaticServiceProvider.Instance.GetRequiredService<ILogger<MemberPasswordHasher>>())
34+
{
35+
}
36+
37+
public MemberPasswordHasher(
38+
LegacyPasswordSecurity legacyPasswordHasher,
39+
IJsonSerializer jsonSerializer,
40+
IOptions<LegacyPasswordMigrationSettings> legacyMachineKeySettings,
41+
ILogger<MemberPasswordHasher> logger)
2042
: base(legacyPasswordHasher, jsonSerializer)
2143
{
44+
_legacyMachineKeySettings = legacyMachineKeySettings;
45+
_logger = logger;
2246
}
2347

2448
/// <summary>
@@ -36,10 +60,21 @@ public override PasswordVerificationResult VerifyHashedPassword(MemberIdentityUs
3660
throw new ArgumentNullException(nameof(user));
3761
}
3862

63+
var isPasswordAlgorithmKnown = user.PasswordConfig.IsNullOrWhiteSpace() == false &&
64+
user.PasswordConfig != Constants.Security.UnknownPasswordConfigJson;
3965
// if there's password config use the base implementation
40-
if (!user.PasswordConfig.IsNullOrWhiteSpace())
66+
if (isPasswordAlgorithmKnown)
4167
{
42-
return base.VerifyHashedPassword(user, hashedPassword, providedPassword);
68+
var result = base.VerifyHashedPassword(user, hashedPassword, providedPassword);
69+
if (result != PasswordVerificationResult.Failed)
70+
{
71+
return result;
72+
}
73+
}
74+
// We need to check for clear text passwords from members as the first thing. This was possible in v8 :(
75+
else if (IsSuccessfulLegacyPassword(hashedPassword, providedPassword))
76+
{
77+
return PasswordVerificationResult.SuccessRehashNeeded;
4378
}
4479

4580
// Else we need to detect what the password is. This will be the case
@@ -66,7 +101,16 @@ public override PasswordVerificationResult VerifyHashedPassword(MemberIdentityUs
66101
return base.VerifyHashedPassword(user, hashedPassword, providedPassword);
67102
}
68103

69-
throw new InvalidOperationException("unable to determine member password hashing algorith");
104+
if (isPasswordAlgorithmKnown)
105+
{
106+
_logger.LogError("Unable to determine member password hashing algorithm");
107+
}
108+
else
109+
{
110+
_logger.LogDebug("Unable to determine member password hashing algorithm, but this can happen when member enters a wrong password, before it has be rehashed");
111+
}
112+
113+
return PasswordVerificationResult.Failed;
70114
}
71115

72116
var isValid = LegacyPasswordSecurity.VerifyPassword(
@@ -76,5 +120,65 @@ public override PasswordVerificationResult VerifyHashedPassword(MemberIdentityUs
76120

77121
return isValid ? PasswordVerificationResult.SuccessRehashNeeded : PasswordVerificationResult.Failed;
78122
}
123+
124+
private bool IsSuccessfulLegacyPassword(string hashedPassword, string providedPassword)
125+
{
126+
if (!string.IsNullOrEmpty(_legacyMachineKeySettings.Value.MachineKeyDecryptionKey))
127+
{
128+
try
129+
{
130+
var decryptedPassword = DecryptLegacyPassword(hashedPassword, _legacyMachineKeySettings.Value.MachineKeyDecryption, _legacyMachineKeySettings.Value.MachineKeyDecryptionKey);
131+
return decryptedPassword == providedPassword;
132+
}
133+
catch (Exception ex)
134+
{
135+
_logger.LogError(ex, "Could not decrypt password even that a DecryptionKey is provided. This means the DecryptionKey is wrong.");
136+
return false;
137+
}
138+
}
139+
140+
var result = LegacyPasswordSecurity.VerifyPassword(Constants.Security.AspNetUmbraco8PasswordHashAlgorithmName, providedPassword, hashedPassword);
141+
return result || LegacyPasswordSecurity.VerifyPassword(Constants.Security.AspNetUmbraco4PasswordHashAlgorithmName, providedPassword, hashedPassword);
142+
}
143+
144+
private static string DecryptLegacyPassword(string encryptedPassword, string algorithmName, string decryptionKey)
145+
{
146+
SymmetricAlgorithm algorithm;
147+
switch (algorithmName)
148+
{
149+
case "AES":
150+
algorithm = new AesCryptoServiceProvider()
151+
{
152+
Key = StringToByteArray(decryptionKey),
153+
IV = new byte[16]
154+
};
155+
break;
156+
default:
157+
throw new NotSupportedException($"The algorithm ({algorithmName}) is not supported");
158+
}
159+
160+
using (algorithm)
161+
{
162+
return DecryptLegacyPassword(encryptedPassword, algorithm);
163+
}
164+
}
165+
166+
private static string DecryptLegacyPassword(string encryptedPassword, SymmetricAlgorithm algorithm)
167+
{
168+
using var memoryStream = new MemoryStream();
169+
ICryptoTransform cryptoTransform = algorithm.CreateDecryptor();
170+
var cryptoStream = new CryptoStream((Stream)memoryStream, cryptoTransform, CryptoStreamMode.Write);
171+
var buf = Convert.FromBase64String(encryptedPassword);
172+
cryptoStream.Write(buf, 0, 32);
173+
cryptoStream.FlushFinalBlock();
174+
175+
return Encoding.Unicode.GetString(memoryStream.ToArray());
176+
}
177+
178+
private static byte[] StringToByteArray(string hex) =>
179+
Enumerable.Range(0, hex.Length)
180+
.Where(x => x % 2 == 0)
181+
.Select(x => Convert.ToByte(hex.Substring(x, 2), 16))
182+
.ToArray();
79183
}
80184
}

src/Umbraco.Infrastructure/Security/MemberUserStore.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,12 @@ private MemberDataChangeType UpdateMemberProperties(IMember member, MemberIdenti
592592
member.PasswordConfiguration = identityUser.PasswordConfig;
593593
}
594594

595+
if (member.PasswordConfiguration != identityUser.PasswordConfig)
596+
{
597+
changeType = MemberDataChangeType.FullSave;
598+
member.PasswordConfiguration = identityUser.PasswordConfig;
599+
}
600+
595601
if (member.SecurityStamp != identityUser.SecurityStamp)
596602
{
597603
changeType = MemberDataChangeType.FullSave;

src/Umbraco.Infrastructure/Security/UmbracoPasswordHasher.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ public override PasswordVerificationResult VerifyHashedPassword(TUser user, stri
5353
if (LegacyPasswordSecurity.SupportHashAlgorithm(deserialized.HashAlgorithm))
5454
{
5555
var result = LegacyPasswordSecurity.VerifyPassword(deserialized.HashAlgorithm, providedPassword, hashedPassword);
56+
57+
//We need to special handle this case, apparently v8 still saves the user algorithm as {"hashAlgorithm":"HMACSHA256"}, when using legacy encoding and hasinging.
58+
if (result == false)
59+
{
60+
result = LegacyPasswordSecurity.VerifyLegacyHashedPassword(providedPassword, hashedPassword);
61+
}
62+
5663
return result
5764
? PasswordVerificationResult.SuccessRehashNeeded
5865
: PasswordVerificationResult.Failed;

0 commit comments

Comments
 (0)