diff --git a/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs index 35458d6eba..dbb96478d6 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs @@ -81,6 +81,39 @@ public interface IUserRepository : IReadWriteQueryRepository /// IUser? GetByUsername(string username, bool includeSecurityData); + /// + /// Gets a user by username for upgrade purposes, this will only return a result if the current runtime state is upgrade. + /// + /// + /// This only resolves the minimum amount of fields required to authorize for an upgrade. + /// We need this to be able to add new columns to the user table. + /// + /// The username to find the user by. + /// An uncached instance. + IUser? GetForUpgradeByUsername(string username) => GetByUsername(username, false); + + /// + /// Gets a user by email for upgrade purposes, this will only return a result if the current runtime state is upgrade. + /// + /// + /// This only resolves the minimum amount of fields required to authorize for an upgrade. + /// We need this to be able to add new columns to the user table. + /// + /// The email to find the user by. + /// An uncached instance. + IUser? GetForUpgradeByEmail(string email) => GetMany().FirstOrDefault(x=>x.Email == email); + + /// + /// Gets a user for upgrade purposes, this will only return a result if the current runtime state is upgrade. + /// + /// + /// This only resolves the minimum amount of fields required to authorize for an upgrade. + /// We need this to be able to add new columns to the user table. + /// + /// The id to find the user by. + /// An uncached instance. + IUser? GetForUpgrade(int id) => Get(id, false); + /// /// Returns a user by id /// diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index b04e9d8850..095585c0e7 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -119,6 +119,13 @@ public interface IUserService : IMembershipUserService /// IProfile? GetProfileByUserName(string username); + /// + /// Get a user by its key. + /// + /// The GUID key of the user. + /// The found user, or null if nothing was found. + Task GetAsync(Guid key) => Task.FromResult(GetAll(0, int.MaxValue, out _).FirstOrDefault(x=>x.Key == key)); + /// /// Gets a user by Id /// diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index 0782431e91..dc3acad3c5 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -251,8 +251,22 @@ internal class UserService : RepositoryService, IUserService { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { - IQuery query = Query().Where(x => x.Email.Equals(email)); - return _userRepository.Get(query)?.FirstOrDefault(); + try + { + IQuery query = Query().Where(x => x.Email.Equals(email)); + return _userRepository.Get(query)?.FirstOrDefault(); + } + catch(DbException) + { + // We also need to catch upgrade state here, because the framework will try to call this to validate the email. + if (IsUpgrading) + { + return _userRepository.GetForUpgradeByEmail(email); + } + + throw; + } + } } @@ -285,7 +299,7 @@ internal class UserService : RepositoryService, IUserService if (IsUpgrading) { // NOTE: this will not be cached - return _userRepository.GetByUsername(username, false); + return _userRepository.GetForUpgradeByUsername(username); } throw; @@ -802,7 +816,7 @@ internal class UserService : RepositoryService, IUserService if (IsUpgrading) { // NOTE: this will not be cached - return _userRepository.Get(id, false); + return _userRepository.GetForUpgrade(id); } throw; @@ -810,6 +824,15 @@ internal class UserService : RepositoryService, IUserService } } + public Task GetAsync(Guid key) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + IQuery query = Query().Where(x => x.Key == key); + return Task.FromResult(_userRepository.Get(query).FirstOrDefault()); + } + } + public IEnumerable GetUsersById(params int[]? ids) { if (ids?.Length <= 0) diff --git a/src/Umbraco.Core/Services/UserServiceExtensions.cs b/src/Umbraco.Core/Services/UserServiceExtensions.cs index f17a266616..bc39b682a5 100644 --- a/src/Umbraco.Core/Services/UserServiceExtensions.cs +++ b/src/Umbraco.Core/Services/UserServiceExtensions.cs @@ -81,6 +81,7 @@ public static class UserServiceExtensions }); } + [Obsolete("Use IUserService.Get that takes a Guid instead. Scheduled for removal in V15.")] public static IUser? GetByKey(this IUserService userService, Guid key) { var id = BitConverter.ToInt32(key.ToByteArray(), 0); diff --git a/src/Umbraco.Infrastructure/Extensions/GuidExtensions.cs b/src/Umbraco.Infrastructure/Extensions/GuidExtensions.cs new file mode 100644 index 0000000000..d2a189bd14 --- /dev/null +++ b/src/Umbraco.Infrastructure/Extensions/GuidExtensions.cs @@ -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); + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index e77d26f088..decf0b984c 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs @@ -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, diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 5e9ef82a13..9dd79a9f2f 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -84,5 +84,6 @@ public class UmbracoPlan : MigrationPlan To("{5F15A1CC-353D-4889-8C7E-F303B4766196}"); To("{69E12556-D9B3-493A-8E8A-65EC89FB658D}"); To("{F2B16CD4-F181-4BEE-81C9-11CF384E6025}"); + To("{A8E01644-9F2E-4988-8341-587EF5B7EA69}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs index 3f75caabee..24a9714a06 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs @@ -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(columns, NewColumnName); scope.Complete(); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUsers.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUsers.cs new file mode 100644 index 0000000000..064d4e6190 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUsers.cs @@ -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; + +/// +/// This is an unscoped migration to support migrating sqlite, since it doesn't support adding columns. +/// See for more information. +/// +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(columns, NewColumnName); + List? dtos = Database.Fetch(); + 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 users = Database.Fetch().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().Do(); + + foreach (UserDto user in users) + { + Database.Insert(Constants.DatabaseSchema.Tables.User, "id", false, user); + } + + MigrateExternalLogins(users); + MigrateTwoFactorLogins(users); + } + + private void MigrateExternalLogins(List userDtos) + { + List? externalLogins = Database.Fetch(); + 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 userDtos) + { + // TODO: TEST ME! + List? twoFactorLoginDtos = Database.Fetch(); + 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(); + UserStartNodeDtos = new HashSet(); + } + + [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; } + + /// + /// This will represent a JSON structure of how the password has been created (i.e hash algorithm, iterations) + /// + [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; + + /// + /// Will hold the media file system relative path of the users custom avatar if they uploaded one + /// + [Column("avatar")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(500)] + public string? Avatar { get; set; } + + /// + /// A Json blob stored for recording tour data for a user + /// + [Column("tourData")] + [NullSetting(NullSetting = NullSettings.Null)] + [SpecialDbType(SpecialDbTypes.NVARCHARMAX)] + public string? TourData { get; set; } + + [ResultColumn] + [Reference(ReferenceType.Many, ReferenceMemberName = "UserId")] + public List UserGroupDtos { get; set; } + + [ResultColumn] + [Reference(ReferenceType.Many, ReferenceMemberName = "UserId")] + public HashSet UserStartNodeDtos { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/UserDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/UserDto.cs index 21c6afde38..f71a8bc60d 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/UserDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/UserDto.cs @@ -18,7 +18,6 @@ public class UserDto UserStartNodeDtos = new HashSet(); } - // 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; } diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs index 17c445576d..4051c0d198 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs @@ -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, diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/UserMapper.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/UserMapper.cs index 92af2773cf..276a522f62 100644 --- a/src/Umbraco.Infrastructure/Persistence/Mappers/UserMapper.cs +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/UserMapper.cs @@ -14,6 +14,7 @@ public sealed class UserMapper : BaseMapper protected override void DefineMaps() { + DefineMap(nameof(User.Key), nameof(UserDto.Key)); DefineMap(nameof(User.Id), nameof(UserDto.Id)); DefineMap(nameof(User.Email), nameof(UserDto.Email)); DefineMap(nameof(User.Username), nameof(UserDto.Login)); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs index fbd1913a58..b2e2ca5847 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs @@ -154,6 +154,49 @@ internal class UserRepository : EntityRepositoryBase, IUserRepositor public IUser? GetByUsername(string username, bool includeSecurityData) => GetWith(sql => sql.Where(x => x.Login == username), includeSecurityData); + public IUser? GetForUpgradeByUsername(string username) => GetUpgradeUserWith(sql => sql.Where(x => x.Login == username)); + + public IUser? GetForUpgradeByEmail(string email) => GetUpgradeUserWith(sql => sql.Where(x => x.Email == email)); + + public IUser? GetForUpgrade(int id) => GetUpgradeUserWith(sql => sql.Where(x => x.Id == id)); + + private IUser? GetUpgradeUserWith(Action> with) + { + if (_runtimeState.Level != RuntimeLevel.Upgrade) + { + return null; + } + + // We'll only return a user if we're in upgrade mode. + Sql sql = SqlContext.Sql() + .Select( + 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(); + + with(sql); + + UserDto? userDto = Database.Fetch(sql).FirstOrDefault(); + + if (userDto is null) + { + return null; + } + + PerformGetReferencedDtos(new List { userDto }); + + return UserFactory.BuildEntity(_globalSettings, userDto); + } + /// /// Returns a user by id /// @@ -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; } diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs index b617ce5a05..d91555ceeb 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs @@ -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; + } + } /// /// Used to construct a new instance without an identity diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index a4ac523681..73b118f71f 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -87,14 +87,16 @@ public class BackOfficeUserStore : UmbracoUserStore - public Task ValidateSessionIdAsync(string? userId, string? sessionId) + public async Task 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; } /// @@ -123,16 +125,16 @@ public class BackOfficeUserStore : UmbracoUserStore(); - 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(); + 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(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 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}"); + } + /// public override Task FindByEmailAsync( string email, @@ -529,11 +569,10 @@ public class BackOfficeUserStore : UmbracoUserStore?>(() => _externalLoginService.GetExternalLogins(userId))); + new Lazy?>(() => _externalLoginService.GetExternalLogins(user.Key))); user.SetTokensCallback( - new Lazy?>(() => _externalLoginService.GetExternalLoginTokens(userId))); + new Lazy?>(() => _externalLoginService.GetExternalLoginTokens(user.Key))); } return user; diff --git a/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs b/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs index 9c3b85af53..a1e03adefc 100644 --- a/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs +++ b/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs @@ -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; diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index 934cefb0b8..6e1931997a 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -243,7 +243,7 @@ public class MemberUserStore : UmbracoUserStore(user)))!; } + protected override Task 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}"); + } + /// public override Task AddLoginAsync(MemberIdentityUser user, UserLoginInfo login, CancellationToken cancellationToken = default) diff --git a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs index 35a8f2eea9..18acc14b1d 100644 --- a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs @@ -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 [EditorBrowsable(EditorBrowsableState.Never)] public override Task AddClaimsAsync(TUser user, IEnumerable 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 throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); } + protected abstract Task 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)); /// diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/NotificationsRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/NotificationsRepositoryTest.cs index 05b9946425..adafdec845 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/NotificationsRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/NotificationsRepositoryTest.cs @@ -147,6 +147,7 @@ public class NotificationsRepositoryTest : UmbracoIntegrationTest { var userDto = new UserDto { + Key = Guid.NewGuid(), Email = "test" + i, Login = "test" + i, Password = "test", @@ -209,6 +210,7 @@ public class NotificationsRepositoryTest : UmbracoIntegrationTest { var userDto = new UserDto { + Key = Guid.NewGuid(), Email = "test" + i, Login = "test" + i, Password = "test", diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs index 9e2a769e74..4e59fac4a6 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs @@ -235,14 +235,16 @@ public class MemberUserStoreTests public async Task GivenIDeleteUser_AndTheUserIsDeletedCorrectly_ThenIShouldGetASuccessResultAsync() { // arrange + var memberKey = new Guid("4B003A55-1DE9-4DEB-95A0-352FFC693D8F"); var sut = CreateSut(); - var fakeUser = new MemberIdentityUser(777); + var fakeUser = new MemberIdentityUser(777) { Key = memberKey }; var fakeCancellationToken = CancellationToken.None; IMemberType fakeMemberType = new MemberType(new MockShortStringHelper(), 77); IMember mockMember = new Member(fakeMemberType) { Id = 777, + Key = memberKey, Name = "fakeName", Email = "fakeemail@umbraco.com", Username = "fakeUsername", @@ -250,6 +252,7 @@ public class MemberUserStoreTests }; _mockMemberService.Setup(x => x.GetById(mockMember.Id)).Returns(mockMember); + _mockMemberService.Setup(x => x.GetByKey(mockMember.Key)).Returns(mockMember); _mockMemberService.Setup(x => x.Delete(mockMember)); // act @@ -258,7 +261,7 @@ public class MemberUserStoreTests // assert Assert.IsTrue(identityResult.Succeeded); Assert.IsTrue(!identityResult.Errors.Any()); - _mockMemberService.Verify(x => x.GetById(mockMember.Id)); + _mockMemberService.Verify(x => x.GetByKey(mockMember.Key)); _mockMemberService.Verify(x => x.Delete(mockMember)); _mockMemberService.VerifyNoOtherCalls(); }