V13: Add Guids to Users (#13868)
* Add MSSQL migration * Make upgrade possible when user doesn't have a key yet * Migrate SQLite * Migrate the external login column * Fix logging in after migration * Handle fake GUID correctly * Make GetByKey async * Resolve external logins by key instead of id * Remove usage of naive UserIdToInt * Dont use ToGuid for property type defaults * Use constant GUID for user groups * Ensure that the same GUID is used to create the root user. * Add migration for two factor logins * Add default implementations * Fix unit test * Remove TODO * Fix integration tests * Add default implementation instead of throwing Co-authored-by: Bjarke Berg <mail@bergmania.dk> * Make SQLServer migration idempotent * Add comment about SQLite * Fix typo --------- Co-authored-by: Bjarke Berg <mail@bergmania.dk>
This commit is contained in:
24
src/Umbraco.Infrastructure/Extensions/GuidExtensions.cs
Normal file
24
src/Umbraco.Infrastructure/Extensions/GuidExtensions.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace Umbraco.Cms.Infrastructure.Extensions;
|
||||
|
||||
public static class GuidExtensions
|
||||
{
|
||||
internal static bool IsFakeGuid(this Guid guid)
|
||||
{
|
||||
var bytes = guid.ToByteArray();
|
||||
|
||||
// Our fake guid is a 32 bit int, converted to a byte representation,
|
||||
// so we can check if everything but the first 4 bytes are 0, if so, we know it's a fake guid.
|
||||
if (bytes[4..].All(x => x == 0))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static int ToInt(this Guid guid)
|
||||
{
|
||||
var bytes = guid.ToByteArray();
|
||||
return BitConverter.ToInt32(bytes, 0);
|
||||
}
|
||||
}
|
||||
@@ -1152,6 +1152,7 @@ internal class DatabaseDataCreator
|
||||
new UserDto
|
||||
{
|
||||
Id = Constants.Security.SuperUserId,
|
||||
Key = new Guid("1E70F841-C261-413B-ABB2-2D68CDB96094"),
|
||||
Disabled = false,
|
||||
NoConsole = false,
|
||||
UserName = "Administrator",
|
||||
@@ -1172,7 +1173,7 @@ internal class DatabaseDataCreator
|
||||
new UserGroupDto
|
||||
{
|
||||
Id = 1,
|
||||
Key = Guid.NewGuid(),
|
||||
Key = new Guid("F3120A06-78D0-404F-8218-0C41F70C5A56"),
|
||||
StartMediaId = -1,
|
||||
StartContentId = -1,
|
||||
Alias = Constants.Security.AdminGroupAlias,
|
||||
@@ -1190,7 +1191,7 @@ internal class DatabaseDataCreator
|
||||
new UserGroupDto
|
||||
{
|
||||
Id = 2,
|
||||
Key = Guid.NewGuid(),
|
||||
Key = new Guid("95F812FB-B401-46C3-9314-60996F414B29"),
|
||||
StartMediaId = -1,
|
||||
StartContentId = -1,
|
||||
Alias = Constants.Security.WriterGroupAlias,
|
||||
@@ -1208,7 +1209,7 @@ internal class DatabaseDataCreator
|
||||
new UserGroupDto
|
||||
{
|
||||
Id = 3,
|
||||
Key = Guid.NewGuid(),
|
||||
Key = new Guid("98BCC501-AC7F-4EB0-B10B-1C8FA6F91141"),
|
||||
StartMediaId = -1,
|
||||
StartContentId = -1,
|
||||
Alias = Constants.Security.EditorGroupAlias,
|
||||
@@ -1226,7 +1227,7 @@ internal class DatabaseDataCreator
|
||||
new UserGroupDto
|
||||
{
|
||||
Id = 4,
|
||||
Key = Guid.NewGuid(),
|
||||
Key = new Guid("AA1EC438-7810-4C72-A636-87A840D8D57F"),
|
||||
StartMediaId = -1,
|
||||
StartContentId = -1,
|
||||
Alias = Constants.Security.TranslatorGroupAlias,
|
||||
@@ -1244,7 +1245,7 @@ internal class DatabaseDataCreator
|
||||
new UserGroupDto
|
||||
{
|
||||
Id = 5,
|
||||
Key = Guid.NewGuid(),
|
||||
Key = new Guid("17627245-521E-4871-9F20-81C809B714FD"),
|
||||
Alias = Constants.Security.SensitiveDataGroupAlias,
|
||||
Name = "Sensitive data",
|
||||
DefaultPermissions = string.Empty,
|
||||
@@ -1407,7 +1408,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 6,
|
||||
UniqueId = 6.ToGuid(),
|
||||
UniqueId = new Guid("B646CA8F-E469-4FC2-A48A-D4DC1AA64A53"),
|
||||
DataTypeId = Constants.DataTypes.ImageCropper,
|
||||
ContentTypeId = 1032,
|
||||
PropertyTypeGroupId = 3,
|
||||
@@ -1423,7 +1424,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 7,
|
||||
UniqueId = 7.ToGuid(),
|
||||
UniqueId = new Guid("A68D453B-1F62-44F4-9F71-0B6BBD43C355"),
|
||||
DataTypeId = Constants.DataTypes.LabelInt,
|
||||
ContentTypeId = 1032,
|
||||
PropertyTypeGroupId = 3,
|
||||
@@ -1439,7 +1440,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 8,
|
||||
UniqueId = 8.ToGuid(),
|
||||
UniqueId = new Guid("854087F6-648B-40ED-BC98-B8A9789E80B9"),
|
||||
DataTypeId = Constants.DataTypes.LabelInt,
|
||||
ContentTypeId = 1032,
|
||||
PropertyTypeGroupId = 3,
|
||||
@@ -1455,7 +1456,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 9,
|
||||
UniqueId = 9.ToGuid(),
|
||||
UniqueId = new Guid("BD4C5ACE-26E3-4A8B-AF1A-E8206A35FA07"),
|
||||
DataTypeId = Constants.DataTypes.LabelBigint,
|
||||
ContentTypeId = 1032,
|
||||
PropertyTypeGroupId = 3,
|
||||
@@ -1471,7 +1472,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 10,
|
||||
UniqueId = 10.ToGuid(),
|
||||
UniqueId = new Guid("F7786FE8-724A-4ED0-B244-72546DB32A92"),
|
||||
DataTypeId = -92,
|
||||
ContentTypeId = 1032,
|
||||
PropertyTypeGroupId = 3,
|
||||
@@ -1491,7 +1492,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 24,
|
||||
UniqueId = 24.ToGuid(),
|
||||
UniqueId = new Guid("A0FB68F3-F427-47A6-AFCE-536FFA5B64E9"),
|
||||
DataTypeId = Constants.DataTypes.Upload,
|
||||
ContentTypeId = 1033,
|
||||
PropertyTypeGroupId = 4,
|
||||
@@ -1507,7 +1508,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 25,
|
||||
UniqueId = 25.ToGuid(),
|
||||
UniqueId = new Guid("3531C0A3-4E0A-4324-A621-B9D3822B071F"),
|
||||
DataTypeId = -92,
|
||||
ContentTypeId = 1033,
|
||||
PropertyTypeGroupId = 4,
|
||||
@@ -1523,7 +1524,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 26,
|
||||
UniqueId = 26.ToGuid(),
|
||||
UniqueId = new Guid("F9527050-59BC-43E4-8FA8-1658D1319FF5"),
|
||||
DataTypeId = Constants.DataTypes.LabelBigint,
|
||||
ContentTypeId = 1033,
|
||||
PropertyTypeGroupId = 4,
|
||||
@@ -1543,7 +1544,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 40,
|
||||
UniqueId = 40.ToGuid(),
|
||||
UniqueId = new Guid("BED8AB97-D85F-44D2-A8B9-AEF6893F9610"),
|
||||
DataTypeId = Constants.DataTypes.UploadVideo,
|
||||
ContentTypeId = 1034,
|
||||
PropertyTypeGroupId = 52,
|
||||
@@ -1559,7 +1560,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 41,
|
||||
UniqueId = 41.ToGuid(),
|
||||
UniqueId = new Guid("EDD2B3FD-1E57-4E57-935E-096DEFCCDC9B"),
|
||||
DataTypeId = -92,
|
||||
ContentTypeId = 1034,
|
||||
PropertyTypeGroupId = 52,
|
||||
@@ -1575,7 +1576,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 42,
|
||||
UniqueId = 42.ToGuid(),
|
||||
UniqueId = new Guid("180EEECF-1F00-409E-8234-BBA967E08B0A"),
|
||||
DataTypeId = Constants.DataTypes.LabelBigint,
|
||||
ContentTypeId = 1034,
|
||||
PropertyTypeGroupId = 52,
|
||||
@@ -1595,7 +1596,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 43,
|
||||
UniqueId = 43.ToGuid(),
|
||||
UniqueId = new Guid("1F48D730-F174-4684-AFAD-A335E59D84A0"),
|
||||
DataTypeId = Constants.DataTypes.UploadAudio,
|
||||
ContentTypeId = 1035,
|
||||
PropertyTypeGroupId = 53,
|
||||
@@ -1611,7 +1612,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 44,
|
||||
UniqueId = 44.ToGuid(),
|
||||
UniqueId = new Guid("1BEE433F-A21A-4031-8E03-AF01BB8D2DE9"),
|
||||
DataTypeId = -92,
|
||||
ContentTypeId = 1035,
|
||||
PropertyTypeGroupId = 53,
|
||||
@@ -1627,7 +1628,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 45,
|
||||
UniqueId = 45.ToGuid(),
|
||||
UniqueId = new Guid("3CBF538A-29AB-4317-A9EB-BBCDF1A54260"),
|
||||
DataTypeId = Constants.DataTypes.LabelBigint,
|
||||
ContentTypeId = 1035,
|
||||
PropertyTypeGroupId = 53,
|
||||
@@ -1647,7 +1648,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 46,
|
||||
UniqueId = 46.ToGuid(),
|
||||
UniqueId = new Guid("E5C8C2D0-2D82-4F01-B53A-45A1D1CBF19C"),
|
||||
DataTypeId = Constants.DataTypes.UploadArticle,
|
||||
ContentTypeId = 1036,
|
||||
PropertyTypeGroupId = 54,
|
||||
@@ -1663,7 +1664,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 47,
|
||||
UniqueId = 47.ToGuid(),
|
||||
UniqueId = new Guid("EF1B4AF7-36DE-45EB-8C18-A2DE07319227"),
|
||||
DataTypeId = -92,
|
||||
ContentTypeId = 1036,
|
||||
PropertyTypeGroupId = 54,
|
||||
@@ -1679,7 +1680,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 48,
|
||||
UniqueId = 48.ToGuid(),
|
||||
UniqueId = new Guid("AAB7D00C-7209-4337-BE3F-A4421C8D79A0"),
|
||||
DataTypeId = Constants.DataTypes.LabelBigint,
|
||||
ContentTypeId = 1036,
|
||||
PropertyTypeGroupId = 54,
|
||||
@@ -1699,7 +1700,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 49,
|
||||
UniqueId = 49.ToGuid(),
|
||||
UniqueId = new Guid("E2A2BDF2-971B-483E-95A1-4104CC06AF26"),
|
||||
DataTypeId = Constants.DataTypes.UploadVectorGraphics,
|
||||
ContentTypeId = 1037,
|
||||
PropertyTypeGroupId = 55,
|
||||
@@ -1715,7 +1716,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 50,
|
||||
UniqueId = 50.ToGuid(),
|
||||
UniqueId = new Guid("0F25A89E-2EB7-49BC-A7B4-759A7E4C69F2"),
|
||||
DataTypeId = -92,
|
||||
ContentTypeId = 1037,
|
||||
PropertyTypeGroupId = 55,
|
||||
@@ -1731,7 +1732,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 51,
|
||||
UniqueId = 51.ToGuid(),
|
||||
UniqueId = new Guid("09A07AFF-861D-4769-A2B0-C165EBD43D39"),
|
||||
DataTypeId = Constants.DataTypes.LabelBigint,
|
||||
ContentTypeId = 1037,
|
||||
PropertyTypeGroupId = 55,
|
||||
@@ -1752,7 +1753,7 @@ internal class DatabaseDataCreator
|
||||
new PropertyTypeDto
|
||||
{
|
||||
Id = 28,
|
||||
UniqueId = 28.ToGuid(),
|
||||
UniqueId = new Guid("70F24C26-1C0E-4053-BD8E-E9E6E4EC4C01"),
|
||||
DataTypeId = Constants.DataTypes.Textarea,
|
||||
ContentTypeId = 1044,
|
||||
PropertyTypeGroupId = 11,
|
||||
|
||||
@@ -84,5 +84,6 @@ public class UmbracoPlan : MigrationPlan
|
||||
To<MigrateDataTypeConfigurations>("{5F15A1CC-353D-4889-8C7E-F303B4766196}");
|
||||
To<AddGuidsToUserGroups>("{69E12556-D9B3-493A-8E8A-65EC89FB658D}");
|
||||
To<AddUserGroup2PermisionTable>("{F2B16CD4-F181-4BEE-81C9-11CF384E6025}");
|
||||
To<AddGuidsToUsers>("{A8E01644-9F2E-4988-8341-587EF5B7EA69}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ public class AddGuidsToUserGroups : UnscopedMigrationBase
|
||||
using IDisposable notificationSuppression = scope.Notifications.Suppress();
|
||||
ScopeDatabase(scope);
|
||||
|
||||
if (ColumnExists(Constants.DatabaseSchema.Tables.UserGroup, NewColumnName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToList();
|
||||
AddColumnIfNotExists<UserGroupDto>(columns, NewColumnName);
|
||||
scope.Complete();
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
using NPoco;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
using Umbraco.Cms.Infrastructure.Scoping;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_13_0_0;
|
||||
|
||||
/// <summary>
|
||||
/// This is an unscoped migration to support migrating sqlite, since it doesn't support adding columns.
|
||||
/// See <see cref="AddGuidsToUserGroups"/> for more information.
|
||||
/// </summary>
|
||||
public class AddGuidsToUsers : UnscopedMigrationBase
|
||||
{
|
||||
private const string NewColumnName = "key";
|
||||
private readonly IScopeProvider _scopeProvider;
|
||||
|
||||
public AddGuidsToUsers(IMigrationContext context, IScopeProvider scopeProvider)
|
||||
: base(context)
|
||||
{
|
||||
_scopeProvider = scopeProvider;
|
||||
}
|
||||
|
||||
protected override void Migrate()
|
||||
{
|
||||
using IScope scope = _scopeProvider.CreateScope();
|
||||
using IDisposable notificationSuppression = scope.Notifications.Suppress();
|
||||
ScopeDatabase(scope);
|
||||
|
||||
if (DatabaseType != DatabaseType.SQLite)
|
||||
{
|
||||
MigrateSqlServer();
|
||||
scope.Complete();
|
||||
return;
|
||||
}
|
||||
|
||||
MigrateSqlite();
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
private void MigrateSqlServer()
|
||||
{
|
||||
var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToList();
|
||||
AddColumnIfNotExists<UserDto>(columns, NewColumnName);
|
||||
List<UserDto>? dtos = Database.Fetch<UserDto>();
|
||||
if (dtos is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MigrateExternalLogins(dtos);
|
||||
MigrateTwoFactorLogins(dtos);
|
||||
}
|
||||
|
||||
private void MigrateSqlite()
|
||||
{
|
||||
if (ColumnExists(Constants.DatabaseSchema.Tables.User, NewColumnName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* We commit the initial transaction started by the scope. This is required in order to disable the foreign keys.
|
||||
* We then begin a new transaction, this transaction will be committed or rolled back by the scope, like normal.
|
||||
* We don't have to worry about re-enabling the foreign keys, since these are enabled by default every time a connection is established.
|
||||
*
|
||||
* Ideally we'd want to do this with the unscoped database we get, however, this cannot be done,
|
||||
* since our scoped database cannot share a connection with the unscoped database, so a new one will be created, which enables the foreign keys.
|
||||
* Similarly we cannot use Database.CompleteTransaction(); since this also closes the connection,
|
||||
* so starting a new transaction would re-enable foreign keys.
|
||||
*/
|
||||
Database.Execute("COMMIT;");
|
||||
Database.Execute("PRAGMA foreign_keys=off;");
|
||||
Database.Execute("BEGIN TRANSACTION;");
|
||||
|
||||
List<UserDto> users = Database.Fetch<OldUserDto>().Select(x => new UserDto
|
||||
{
|
||||
Id = x.Id,
|
||||
Key = Guid.NewGuid(),
|
||||
Disabled = x.Disabled,
|
||||
NoConsole = x.NoConsole,
|
||||
UserName = x.UserName,
|
||||
Login = x.Login,
|
||||
Password = x.Password,
|
||||
PasswordConfig = x.PasswordConfig,
|
||||
Email = x.Email,
|
||||
UserLanguage = x.UserLanguage,
|
||||
SecurityStampToken = x.SecurityStampToken,
|
||||
FailedLoginAttempts = x.FailedLoginAttempts,
|
||||
LastLockoutDate = x.LastLockoutDate,
|
||||
LastPasswordChangeDate = x.LastPasswordChangeDate,
|
||||
LastLoginDate = x.LastLoginDate,
|
||||
EmailConfirmedDate = x.EmailConfirmedDate,
|
||||
InvitedDate = x.InvitedDate,
|
||||
CreateDate = x.CreateDate,
|
||||
UpdateDate = x.UpdateDate,
|
||||
Avatar = x.Avatar,
|
||||
TourData = x.TourData,
|
||||
}).ToList();
|
||||
|
||||
Delete.Table(Constants.DatabaseSchema.Tables.User).Do();
|
||||
Create.Table<UserDto>().Do();
|
||||
|
||||
foreach (UserDto user in users)
|
||||
{
|
||||
Database.Insert(Constants.DatabaseSchema.Tables.User, "id", false, user);
|
||||
}
|
||||
|
||||
MigrateExternalLogins(users);
|
||||
MigrateTwoFactorLogins(users);
|
||||
}
|
||||
|
||||
private void MigrateExternalLogins(List<UserDto> userDtos)
|
||||
{
|
||||
List<ExternalLoginDto>? externalLogins = Database.Fetch<ExternalLoginDto>();
|
||||
if (externalLogins is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ExternalLoginDto externalLogin in externalLogins)
|
||||
{
|
||||
UserDto? associatedUser = userDtos.FirstOrDefault(x => x.Id.ToGuid() == externalLogin.UserOrMemberKey);
|
||||
if (associatedUser is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
externalLogin.UserOrMemberKey = associatedUser.Key;
|
||||
Database.Update(externalLogin);
|
||||
}
|
||||
}
|
||||
|
||||
private void MigrateTwoFactorLogins(List<UserDto> userDtos)
|
||||
{
|
||||
// TODO: TEST ME!
|
||||
List<TwoFactorLoginDto>? twoFactorLoginDtos = Database.Fetch<TwoFactorLoginDto>();
|
||||
if (twoFactorLoginDtos is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TwoFactorLoginDto twoFactorLoginDto in twoFactorLoginDtos)
|
||||
{
|
||||
UserDto? associatedUser = userDtos.FirstOrDefault(x => x.Id.ToGuid() == twoFactorLoginDto.UserOrMemberKey);
|
||||
|
||||
if (associatedUser is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
twoFactorLoginDto.UserOrMemberKey = associatedUser.Key;
|
||||
Database.Update(twoFactorLoginDto);
|
||||
}
|
||||
}
|
||||
|
||||
[TableName(TableName)]
|
||||
[PrimaryKey("id", AutoIncrement = true)]
|
||||
[ExplicitColumns]
|
||||
public class OldUserDto
|
||||
{
|
||||
public const string TableName = Constants.DatabaseSchema.Tables.User;
|
||||
|
||||
public OldUserDto()
|
||||
{
|
||||
UserGroupDtos = new List<UserGroupDto>();
|
||||
UserStartNodeDtos = new HashSet<UserStartNodeDto>();
|
||||
}
|
||||
|
||||
[Column("id")]
|
||||
[PrimaryKeyColumn(Name = "PK_user")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("userDisabled")]
|
||||
[Constraint(Default = "0")]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
[Column("userNoConsole")]
|
||||
[Constraint(Default = "0")]
|
||||
public bool NoConsole { get; set; }
|
||||
|
||||
[Column("userName")] public string UserName { get; set; } = null!;
|
||||
|
||||
[Column("userLogin")]
|
||||
[Length(125)]
|
||||
[Index(IndexTypes.NonClustered)]
|
||||
public string? Login { get; set; }
|
||||
|
||||
[Column("userPassword")] [Length(500)] public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// This will represent a JSON structure of how the password has been created (i.e hash algorithm, iterations)
|
||||
/// </summary>
|
||||
[Column("passwordConfig")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
[Length(500)]
|
||||
public string? PasswordConfig { get; set; }
|
||||
|
||||
[Column("userEmail")] public string Email { get; set; } = null!;
|
||||
|
||||
[Column("userLanguage")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
[Length(10)]
|
||||
public string? UserLanguage { get; set; }
|
||||
|
||||
[Column("securityStampToken")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
[Length(255)]
|
||||
public string? SecurityStampToken { get; set; }
|
||||
|
||||
[Column("failedLoginAttempts")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
public int? FailedLoginAttempts { get; set; }
|
||||
|
||||
[Column("lastLockoutDate")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
public DateTime? LastLockoutDate { get; set; }
|
||||
|
||||
[Column("lastPasswordChangeDate")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
public DateTime? LastPasswordChangeDate { get; set; }
|
||||
|
||||
[Column("lastLoginDate")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
|
||||
[Column("emailConfirmedDate")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
public DateTime? EmailConfirmedDate { get; set; }
|
||||
|
||||
[Column("invitedDate")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
public DateTime? InvitedDate { get; set; }
|
||||
|
||||
[Column("createDate")]
|
||||
[NullSetting(NullSetting = NullSettings.NotNull)]
|
||||
[Constraint(Default = SystemMethods.CurrentDateTime)]
|
||||
public DateTime CreateDate { get; set; } = DateTime.Now;
|
||||
|
||||
[Column("updateDate")]
|
||||
[NullSetting(NullSetting = NullSettings.NotNull)]
|
||||
[Constraint(Default = SystemMethods.CurrentDateTime)]
|
||||
public DateTime UpdateDate { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// Will hold the media file system relative path of the users custom avatar if they uploaded one
|
||||
/// </summary>
|
||||
[Column("avatar")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
[Length(500)]
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A Json blob stored for recording tour data for a user
|
||||
/// </summary>
|
||||
[Column("tourData")]
|
||||
[NullSetting(NullSetting = NullSettings.Null)]
|
||||
[SpecialDbType(SpecialDbTypes.NVARCHARMAX)]
|
||||
public string? TourData { get; set; }
|
||||
|
||||
[ResultColumn]
|
||||
[Reference(ReferenceType.Many, ReferenceMemberName = "UserId")]
|
||||
public List<UserGroupDto> UserGroupDtos { get; set; }
|
||||
|
||||
[ResultColumn]
|
||||
[Reference(ReferenceType.Many, ReferenceMemberName = "UserId")]
|
||||
public HashSet<UserStartNodeDto> UserStartNodeDtos { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ public class UserDto
|
||||
UserStartNodeDtos = new HashSet<UserStartNodeDto>();
|
||||
}
|
||||
|
||||
// TODO: We need to add a GUID for users and track external logins with that instead of the INT
|
||||
[Column("id")]
|
||||
[PrimaryKeyColumn(Name = "PK_user")]
|
||||
public int Id { get; set; }
|
||||
@@ -27,6 +26,12 @@ public class UserDto
|
||||
[Constraint(Default = "0")]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
[Column("key")]
|
||||
[NullSetting(NullSetting = NullSettings.NotNull)]
|
||||
[Constraint(Default = SystemMethods.NewGuid)]
|
||||
[Index(IndexTypes.UniqueNonClustered, Name = "IX_umbracoUser_userKey")]
|
||||
public Guid Key { get; set; }
|
||||
|
||||
[Column("userNoConsole")]
|
||||
[Constraint(Default = "0")]
|
||||
public bool NoConsole { get; set; }
|
||||
|
||||
@@ -9,7 +9,12 @@ internal static class UserFactory
|
||||
{
|
||||
public static IUser BuildEntity(GlobalSettings globalSettings, UserDto dto)
|
||||
{
|
||||
var guidId = dto.Id.ToGuid();
|
||||
Guid key = dto.Key;
|
||||
// This should only happen if the user is still not migrated to have a true key.
|
||||
if (key == Guid.Empty)
|
||||
{
|
||||
key = dto.Id.ToGuid();
|
||||
}
|
||||
|
||||
var user = new User(globalSettings, dto.Id, dto.UserName, dto.Email, dto.Login, dto.Password,
|
||||
dto.PasswordConfig,
|
||||
@@ -23,7 +28,7 @@ internal static class UserFactory
|
||||
{
|
||||
user.DisableChangeTracking();
|
||||
|
||||
user.Key = guidId;
|
||||
user.Key = key;
|
||||
user.IsLockedOut = dto.NoConsole;
|
||||
user.IsApproved = dto.Disabled == false;
|
||||
user.Language = dto.UserLanguage;
|
||||
@@ -54,6 +59,7 @@ internal static class UserFactory
|
||||
{
|
||||
var dto = new UserDto
|
||||
{
|
||||
Key = entity.Key,
|
||||
Disabled = entity.IsApproved == false,
|
||||
Email = entity.Email,
|
||||
Login = entity.Username,
|
||||
@@ -66,8 +72,7 @@ internal static class UserFactory
|
||||
FailedLoginAttempts = entity.FailedPasswordAttempts,
|
||||
LastLockoutDate = entity.LastLockoutDate == DateTime.MinValue ? null : entity.LastLockoutDate,
|
||||
LastLoginDate = entity.LastLoginDate == DateTime.MinValue ? null : entity.LastLoginDate,
|
||||
LastPasswordChangeDate =
|
||||
entity.LastPasswordChangeDate == DateTime.MinValue ? null : entity.LastPasswordChangeDate,
|
||||
LastPasswordChangeDate = entity.LastPasswordChangeDate == DateTime.MinValue ? null : entity.LastPasswordChangeDate,
|
||||
CreateDate = entity.CreateDate,
|
||||
UpdateDate = entity.UpdateDate,
|
||||
Avatar = entity.Avatar,
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class UserMapper : BaseMapper
|
||||
|
||||
protected override void DefineMaps()
|
||||
{
|
||||
DefineMap<User, UserDto>(nameof(User.Key), nameof(UserDto.Key));
|
||||
DefineMap<User, UserDto>(nameof(User.Id), nameof(UserDto.Id));
|
||||
DefineMap<User, UserDto>(nameof(User.Email), nameof(UserDto.Email));
|
||||
DefineMap<User, UserDto>(nameof(User.Username), nameof(UserDto.Login));
|
||||
|
||||
@@ -154,6 +154,49 @@ internal class UserRepository : EntityRepositoryBase<int, IUser>, IUserRepositor
|
||||
public IUser? GetByUsername(string username, bool includeSecurityData) =>
|
||||
GetWith(sql => sql.Where<UserDto>(x => x.Login == username), includeSecurityData);
|
||||
|
||||
public IUser? GetForUpgradeByUsername(string username) => GetUpgradeUserWith(sql => sql.Where<UserDto>(x => x.Login == username));
|
||||
|
||||
public IUser? GetForUpgradeByEmail(string email) => GetUpgradeUserWith(sql => sql.Where<UserDto>(x => x.Email == email));
|
||||
|
||||
public IUser? GetForUpgrade(int id) => GetUpgradeUserWith(sql => sql.Where<UserDto>(x => x.Id == id));
|
||||
|
||||
private IUser? GetUpgradeUserWith(Action<Sql<ISqlContext>> with)
|
||||
{
|
||||
if (_runtimeState.Level != RuntimeLevel.Upgrade)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// We'll only return a user if we're in upgrade mode.
|
||||
Sql<ISqlContext> sql = SqlContext.Sql()
|
||||
.Select<UserDto>(
|
||||
dto => dto.Id,
|
||||
dto => dto.UserName,
|
||||
dto => dto.Email,
|
||||
dto => dto.Login,
|
||||
dto => dto.Password,
|
||||
dto => dto.PasswordConfig,
|
||||
dto => dto.SecurityStampToken,
|
||||
dto => dto.UserLanguage,
|
||||
dto => dto.LastLockoutDate,
|
||||
dto => dto.Disabled,
|
||||
dto => dto.NoConsole)
|
||||
.From<UserDto>();
|
||||
|
||||
with(sql);
|
||||
|
||||
UserDto? userDto = Database.Fetch<UserDto>(sql).FirstOrDefault();
|
||||
|
||||
if (userDto is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PerformGetReferencedDtos(new List<UserDto> { userDto });
|
||||
|
||||
return UserFactory.BuildEntity(_globalSettings, userDto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a user by id
|
||||
/// </summary>
|
||||
@@ -265,7 +308,8 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0
|
||||
if (DateTime.UtcNow - found.LastValidatedUtc > _globalSettings.TimeOut)
|
||||
{
|
||||
//timeout detected, update the record
|
||||
Logger.LogDebug("ClearLoginSession for sessionId {sessionId}", sessionId);ClearLoginSession(sessionId);
|
||||
Logger.LogDebug("ClearLoginSession for sessionId {sessionId}", sessionId);
|
||||
ClearLoginSession(sessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,17 @@ public class BackOfficeIdentityUser : UmbracoIdentityUser
|
||||
set => BeingDirty.SetPropertyValueAndDetectChanges(value, ref _culture!, nameof(Culture));
|
||||
}
|
||||
|
||||
public Guid Key => UserIdToInt(Id).ToGuid();
|
||||
private Guid _key;
|
||||
|
||||
public Guid Key
|
||||
{
|
||||
get => _key;
|
||||
set
|
||||
{
|
||||
_key = value;
|
||||
HasIdentity = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used to construct a new instance without an identity
|
||||
|
||||
@@ -87,14 +87,16 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ValidateSessionIdAsync(string? userId, string? sessionId)
|
||||
public async Task<bool> ValidateSessionIdAsync(string? userId, string? sessionId)
|
||||
{
|
||||
if (Guid.TryParse(sessionId, out Guid guidSessionId))
|
||||
{
|
||||
return Task.FromResult(_userService.ValidateLoginSession(UserIdToInt(userId), guidSessionId));
|
||||
// We need to resolve the id from the key here...
|
||||
var id = await ResolveEntityIdFromIdentityId(userId);
|
||||
return _userService.ValidateLoginSession(id, guidSessionId);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -123,16 +125,16 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
|
||||
}
|
||||
|
||||
if (user.Email is null || user.UserName is null)
|
||||
{
|
||||
throw new InvalidOperationException("Email and UserName is required.");
|
||||
}
|
||||
{
|
||||
throw new InvalidOperationException("Email and UserName is required.");
|
||||
}
|
||||
|
||||
// the password must be 'something' it could be empty if authenticating
|
||||
// with an external provider so we'll just generate one and prefix it, the
|
||||
// prefix will help us determine if the password hasn't actually been specified yet.
|
||||
// this will hash the guid with a salt so should be nicely random
|
||||
var aspHasher = new PasswordHasher<BackOfficeIdentityUser>();
|
||||
var emptyPasswordValue = Constants.Security.EmptyPasswordPrefix +
|
||||
// the password must be 'something' it could be empty if authenticating
|
||||
// with an external provider so we'll just generate one and prefix it, the
|
||||
// prefix will help us determine if the password hasn't actually been specified yet.
|
||||
// this will hash the guid with a salt so should be nicely random
|
||||
var aspHasher = new PasswordHasher<BackOfficeIdentityUser>();
|
||||
var emptyPasswordValue = Constants.Security.EmptyPasswordPrefix +
|
||||
aspHasher.HashPassword(user, Guid.NewGuid().ToString("N"));
|
||||
|
||||
var userEntity = new User(_globalSettings, user.Name, user.Email, user.UserName, emptyPasswordValue)
|
||||
@@ -252,14 +254,13 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
}
|
||||
|
||||
var userId = UserIdToInt(user.Id);
|
||||
IUser? found = _userService.GetUserById(userId);
|
||||
if (found != null)
|
||||
IUser? found = FindUserFromString(user.Id);
|
||||
if (found is not null)
|
||||
{
|
||||
_userService.Delete(found);
|
||||
}
|
||||
|
||||
_externalLoginService.DeleteUserLogins(userId.ToGuid());
|
||||
_externalLoginService.DeleteUserLogins(user.Key);
|
||||
|
||||
return Task.FromResult(IdentityResult.Success);
|
||||
}
|
||||
@@ -286,7 +287,7 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ThrowIfDisposed();
|
||||
|
||||
IUser? user = _userService.GetUserById(UserIdToInt(userId));
|
||||
IUser? user = FindUserFromString(userId);
|
||||
if (user == null)
|
||||
{
|
||||
return Task.FromResult((BackOfficeIdentityUser?)null)!;
|
||||
@@ -295,6 +296,45 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
|
||||
return Task.FromResult(AssignLoginsCallback(_mapper.Map<BackOfficeIdentityUser>(user)))!;
|
||||
}
|
||||
|
||||
private IUser? FindUserFromString(string userId)
|
||||
{
|
||||
// We could use ResolveEntityIdFromIdentityId here, but that would require multiple DB calls, so let's not.
|
||||
if (TryConvertIdentityIdToInt(userId, out var id))
|
||||
{
|
||||
return _userService.GetUserById(id);
|
||||
}
|
||||
|
||||
// We couldn't directly convert the ID to an int, this is because the user logged in with external login.
|
||||
// So we need to look up the user by key.
|
||||
if (Guid.TryParse(userId, out Guid key))
|
||||
{
|
||||
return _userService.GetAsync(key).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unable to resolve user with ID {userId}");
|
||||
}
|
||||
|
||||
protected override async Task<int> ResolveEntityIdFromIdentityId(string? identityId)
|
||||
{
|
||||
if (TryConvertIdentityIdToInt(identityId, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// We couldn't directly convert the ID to an int, this is because the user logged in with external login.
|
||||
// So we need to look up the user by key, and then get the ID.
|
||||
if (Guid.TryParse(identityId, out Guid key))
|
||||
{
|
||||
IUser? user = await _userService.GetAsync(key);
|
||||
if (user is not null)
|
||||
{
|
||||
return user.Id;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unable to resolve a user id from {identityId}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<BackOfficeIdentityUser?> FindByEmailAsync(
|
||||
string email,
|
||||
@@ -529,11 +569,10 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
|
||||
{
|
||||
if (user != null)
|
||||
{
|
||||
var userId = UserIdToInt(user.Id).ToGuid();
|
||||
user.SetLoginsCallback(
|
||||
new Lazy<IEnumerable<IIdentityUserLogin>?>(() => _externalLoginService.GetExternalLogins(userId)));
|
||||
new Lazy<IEnumerable<IIdentityUserLogin>?>(() => _externalLoginService.GetExternalLogins(user.Key)));
|
||||
user.SetTokensCallback(
|
||||
new Lazy<IEnumerable<IIdentityUserToken>?>(() => _externalLoginService.GetExternalLoginTokens(userId)));
|
||||
new Lazy<IEnumerable<IIdentityUserToken>?>(() => _externalLoginService.GetExternalLoginTokens(user.Key)));
|
||||
}
|
||||
|
||||
return user;
|
||||
|
||||
@@ -69,6 +69,7 @@ public class IdentityMapDefinition : IMapDefinition
|
||||
private void Map(IUser source, BackOfficeIdentityUser target)
|
||||
{
|
||||
// NOTE: Groups/Roles are set in the BackOfficeIdentityUser ctor
|
||||
target.Key = source.Key;
|
||||
target.CalculatedMediaStartNodeIds = source.CalculateMediaStartNodeIds(_entityService, _appCaches);
|
||||
target.CalculatedContentStartNodeIds = source.CalculateContentStartNodeIds(_entityService, _appCaches);
|
||||
target.Email = source.Email;
|
||||
|
||||
@@ -243,7 +243,7 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
}
|
||||
|
||||
IMember? found = _memberService.GetById(UserIdToInt(user.Id));
|
||||
IMember? found = _memberService.GetByKey(user.Key);
|
||||
if (found != null)
|
||||
{
|
||||
_memberService.Delete(found);
|
||||
@@ -321,7 +321,8 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
|
||||
IMember? user = Guid.TryParse(userId, out Guid key)
|
||||
? _memberService.GetByKey(key)
|
||||
: _memberService.GetById(UserIdToInt(userId));
|
||||
: _memberService.GetById(ResolveEntityIdFromIdentityId(userId).GetAwaiter().GetResult());
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return Task.FromResult((MemberIdentityUser)null!)!;
|
||||
@@ -330,6 +331,25 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
return Task.FromResult(AssignLoginsCallback(_mapper.Map<MemberIdentityUser>(user)))!;
|
||||
}
|
||||
|
||||
protected override Task<int> ResolveEntityIdFromIdentityId(string? identityId)
|
||||
{
|
||||
if (TryConvertIdentityIdToInt(identityId, out var id))
|
||||
{
|
||||
return Task.FromResult(id);
|
||||
}
|
||||
|
||||
if (Guid.TryParse(identityId, out Guid key))
|
||||
{
|
||||
IMember? member = _memberService.GetByKey(key);
|
||||
if (member is not null)
|
||||
{
|
||||
return Task.FromResult(member.Id);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unable to resolve user with ID {identityId}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task AddLoginAsync(MemberIdentityUser user, UserLoginInfo login,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Umbraco.Cms.Infrastructure.Extensions;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.Security;
|
||||
@@ -31,6 +32,7 @@ public abstract class UmbracoUserStore<TUser, TRole>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public override Task AddClaimsAsync(TUser user, IEnumerable<Claim> claims, CancellationToken cancellationToken = default) => throw new NotImplementedException();
|
||||
|
||||
[Obsolete("Use TryConvertIdentityIdToInt instead. Scheduled for removal in V15.")]
|
||||
protected static int UserIdToInt(string? userId)
|
||||
{
|
||||
if (int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
|
||||
@@ -47,6 +49,34 @@ public abstract class UmbracoUserStore<TUser, TRole>
|
||||
throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture");
|
||||
}
|
||||
|
||||
protected abstract Task<int> ResolveEntityIdFromIdentityId(string? identityId);
|
||||
|
||||
protected static bool TryConvertIdentityIdToInt(string? userId, out int intId)
|
||||
{
|
||||
// The userId can in this case be one of three things
|
||||
// 1. An int - this means that the user logged in normally, this is fine, we parse it and return it.
|
||||
// 2. A fake Guid - this means that the user logged in using an external login provider, but we haven't migrated the users to have a key yet, so we need to convert it to an int.
|
||||
// 3. A Guid - this means that the user logged in using an external login provider, so we have to resolve the user by key.
|
||||
|
||||
if (int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
|
||||
{
|
||||
intId = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Guid.TryParse(userId, out Guid key))
|
||||
{
|
||||
if (key.IsFakeGuid())
|
||||
{
|
||||
intId = key.ToInt();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
intId = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static string UserIdToString(int userId) => string.Intern(userId.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user