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:
Mole
2023-02-22 12:33:41 +01:00
committed by GitHub
parent f734f1fc6a
commit 46cc9d6a97
20 changed files with 587 additions and 61 deletions

View 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);
}
}

View File

@@ -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,

View File

@@ -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}");
}
}

View File

@@ -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();

View File

@@ -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; }
}
}

View File

@@ -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; }

View File

@@ -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,

View File

@@ -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));

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)

View File

@@ -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>