using System.Data.Common; using System.Linq.Expressions; using System.Reflection; using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NPoco; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Cms.Infrastructure.Persistence.Factories; using Umbraco.Cms.Infrastructure.Persistence.Mappers; using Umbraco.Cms.Infrastructure.Persistence.Querying; using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; /// /// Represents the UserRepository for doing CRUD operations for /// internal sealed class UserRepository : EntityRepositoryBase, IUserRepository { private readonly IMapperCollection _mapperCollection; private readonly GlobalSettings _globalSettings; private readonly UserPasswordConfigurationSettings _passwordConfiguration; private readonly IJsonSerializer _jsonSerializer; private readonly IRuntimeState _runtimeState; private string? _passwordConfigJson; private bool _passwordConfigInitialized; private readonly Lock _sqliteValidateSessionLock = new(); private readonly IDictionary _permissionMappers; /// /// Initializes a new instance of the class. /// /// The scope accessor. /// The application caches. /// The logger. /// /// A dictionary specifying the configuration for user passwords. If this is null then no /// password configuration will be persisted or read. /// /// The global settings. /// The password configuration. /// The JSON serializer. /// State of the runtime. /// The permission mappers. /// /// mapperCollection /// or /// globalSettings /// or /// passwordConfiguration /// public UserRepository( IScopeAccessor scopeAccessor, AppCaches appCaches, ILogger logger, IMapperCollection mapperCollection, IOptions globalSettings, IOptions passwordConfiguration, IJsonSerializer jsonSerializer, IRuntimeState runtimeState, IEnumerable permissionMappers) : base(scopeAccessor, appCaches, logger) { _mapperCollection = mapperCollection ?? throw new ArgumentNullException(nameof(mapperCollection)); _globalSettings = globalSettings.Value ?? throw new ArgumentNullException(nameof(globalSettings)); _passwordConfiguration = passwordConfiguration.Value ?? throw new ArgumentNullException(nameof(passwordConfiguration)); _jsonSerializer = jsonSerializer; _runtimeState = runtimeState; _permissionMappers = permissionMappers.ToDictionary(x => x.Context); } /// /// Returns a serialized dictionary of the password configuration that is stored against the user in the database /// private string? DefaultPasswordConfigJson { get { if (_passwordConfigInitialized) { return _passwordConfigJson; } var passwordConfig = new PersistedPasswordSettings { HashAlgorithm = _passwordConfiguration.HashAlgorithmType }; _passwordConfigJson = passwordConfig == null ? null : _jsonSerializer.Serialize(passwordConfig); _passwordConfigInitialized = true; return _passwordConfigJson; } } private IEnumerable ConvertFromDtos(IEnumerable dtos) => dtos.Select(x => UserFactory.BuildEntity(_globalSettings, x, _permissionMappers)); #region Overrides of RepositoryBase protected override IUser? PerformGet(Guid key) { Sql sql = SqlContext.Sql() .Select() .From() .Where(x => x.Key == key); List? dtos = Database.Fetch(sql); if (dtos.Count == 0) { return null; } PerformGetReferencedDtos(dtos); return UserFactory.BuildEntity(_globalSettings, dtos[0], _permissionMappers); } protected override Guid GetEntityId(IUser entity) => entity.Key; /// /// Returns a user by username /// /// /// /// Can be used for slightly faster user lookups if the result doesn't require security data (i.e. groups, apps & start nodes). /// This is really only used for a shim in order to upgrade to 7.6. /// /// /// A non cached instance /// 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, _permissionMappers); } /// /// Returns a user by id /// /// /// /// This is really only used for a shim in order to upgrade to 7.6 but could be used /// for slightly faster user lookups if the result doesn't require security data (i.e. groups, apps & start nodes) /// /// /// A non cached instance /// public IUser? Get(int? id, bool includeSecurityData) => GetWith(sql => sql.Where(x => x.Id == id), includeSecurityData); public IProfile? GetProfile(string username) { UserDto? dto = GetDtoWith(sql => sql.Where(x => x.Login == username), false); return dto == null ? null : new UserProfile(dto.Id, dto.UserName); } public IProfile? GetProfile(int id) { UserDto? dto = GetDtoWith(sql => sql.Where(x => x.Id == id), false); return dto == null ? null : new UserProfile(dto.Id, dto.UserName); } public IDictionary GetUserStates() { // These keys in this query map to the `Umbraco.Core.Models.Membership.UserState` enum var sql = @"SELECT -1 AS [Key], COUNT(id) AS [Value] FROM umbracoUser UNION SELECT 0 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0 AND userNoConsole = 0 AND lastLoginDate IS NOT NULL UNION SELECT 1 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 1 UNION SELECT 2 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userNoConsole = 1 UNION SELECT 3 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE lastLoginDate IS NULL AND userDisabled = 1 AND invitedDate IS NOT NULL UNION SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0 AND userNoConsole = 0 AND lastLoginDate IS NULL"; Dictionary? result = Database.Dictionary(sql); return result.ToDictionary(x => (UserState)x.Key, x => x.Value); } public Guid CreateLoginSession(int? userId, string requestingIpAddress, bool cleanStaleSessions = true) { DateTime now = DateTime.UtcNow; var dto = new UserLoginDto { UserId = userId, IpAddress = requestingIpAddress, LoggedInUtc = now, LastValidatedUtc = now, LoggedOutUtc = null, SessionId = Guid.NewGuid() }; Database.Insert(dto); if (cleanStaleSessions) { ClearLoginSessions(TimeSpan.FromDays(15)); } return dto.SessionId; } public bool ValidateLoginSession(int userId, Guid sessionId) { // HACK: Avoid a deadlock - BackOfficeCookieOptions OnValidatePrincipal // After existing session times out and user logs in again ~ 4 requests come in at once that hit the // "update the validate date" code path, check up the call stack there are a few variables that can make this not occur. // TODO: more generic fix, do something with ForUpdate? wait on a mutex? add a distributed lock? etc. if (Database.DatabaseType.IsSqlite()) { lock (_sqliteValidateSessionLock) { return ValidateLoginSessionInternal(userId, sessionId); } } return ValidateLoginSessionInternal(userId, sessionId); } private bool ValidateLoginSessionInternal(int userId, Guid sessionId) { // with RepeatableRead transaction mode, read-then-update operations can // cause deadlocks, and the ForUpdate() hint is required to tell the database // to acquire an exclusive lock when reading // that query is going to run a *lot*, make it a template SqlTemplate t = SqlContext.Templates.Get("Umbraco.Core.UserRepository.ValidateLoginSession", s => s .Select() .From() .Where(x => x.SessionId == SqlTemplate.Arg("sessionId")) .ForUpdate() .SelectTop(1)); // Stick at end, SQL server syntax provider will insert at start of query after "select ", but sqlite will append limit to end. Sql sql = t.Sql(sessionId); UserLoginDto? found = Database.FirstOrDefault(sql); if (found == null || found.UserId != userId || found.LoggedOutUtc.HasValue) { return false; } //now detect if there's been a timeout if (DateTime.UtcNow - found.LastValidatedUtc > _globalSettings.TimeOut) { //timeout detected, update the record if (Logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) { Logger.LogDebug("ClearLoginSession for sessionId {sessionId}", sessionId); } ClearLoginSession(sessionId); return false; } //update the validate date if (Logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) { Logger.LogDebug("Updating LastValidatedUtc for sessionId {sessionId}", sessionId); } found.LastValidatedUtc = DateTime.UtcNow; Database.Update(found); return true; } public int ClearLoginSessions(int userId) => Database.Delete(Sql().Where(x => x.UserId == userId)); public int ClearLoginSessions(TimeSpan timespan) { DateTime fromDate = DateTime.UtcNow - timespan; return Database.Delete(Sql().Where(x => x.LastValidatedUtc < fromDate)); } public void ClearLoginSession(Guid sessionId) => // TODO: why is that one updating and not deleting? Database.Execute(Sql() .Update(u => u.Set(x => x.LoggedOutUtc, DateTime.UtcNow)) .Where(x => x.SessionId == sessionId)); protected override IEnumerable PerformGetAll(params Guid[]? ids) { List dtos = ids?.Length == 0 ? GetDtosWith(null, true) : GetDtosWith(sql => sql.WhereIn(x => x.Key, ids), true); var users = new IUser[dtos.Count]; var i = 0; foreach (UserDto dto in dtos) { users[i++] = UserFactory.BuildEntity(_globalSettings, dto, _permissionMappers); } return users; } protected override IEnumerable PerformGetByQuery(IQuery query) { var dtos = GetDtosWith(sql => new SqlTranslator(sql, query).Translate(), true) .DistinctBy(x => x.Id) .ToList(); var users = new IUser[dtos.Count]; var i = 0; foreach (UserDto dto in dtos) { users[i++] = UserFactory.BuildEntity(_globalSettings, dto, _permissionMappers); } return users; } private IUser? GetWith(Action> with, bool includeReferences) { UserDto? dto = GetDtoWith(with, includeReferences); return dto == null ? null : UserFactory.BuildEntity(_globalSettings, dto, _permissionMappers); } private UserDto? GetDtoWith(Action> with, bool includeReferences) { List dtos = GetDtosWith(with, includeReferences); return dtos.FirstOrDefault(); } private List GetDtosWith(Action>? with, bool includeReferences) { Sql sql = SqlContext.Sql() .Select() .From(); with?.Invoke(sql); List? dtos = Database.Fetch(sql); if (includeReferences) { PerformGetReferencedDtos(dtos); } return dtos; } // NPoco cannot fetch 2+ references at a time // plus it creates a combinatorial explosion // better use extra queries // unfortunately, SqlCe doesn't support multiple result sets private void PerformGetReferencedDtos(List dtos) { if (dtos.Count == 0) { return; } List userIds = dtos.Count == 1 ? new List { dtos[0].Id } : dtos.Select(x => x.Id).ToList(); Dictionary? xUsers = dtos.Count == 1 ? null : dtos.ToDictionary(x => x.Id, x => x); var groupIds = new List(); var groupKeys = new List(); Sql sql; try { sql = SqlContext.Sql() .Select(x => x.Id, x => x.Key) .From() .InnerJoin().On((left, right) => left.Id == right.UserGroupId) .WhereIn(x => x.UserId, userIds); List? userGroups = Database.Fetch(sql); groupKeys = userGroups.Select(x => x.Key).ToList(); } catch (DbException) { // ignore doing upgrade, as we know the Key potentially do not exists if (_runtimeState.Level != RuntimeLevel.Upgrade) { throw; } } // get users2groups sql = SqlContext.Sql() .Select() .From() .WhereIn(x => x.UserId, userIds); List? user2Groups = Database.Fetch(sql); if (groupIds.Any() is false) { //this can happen if we are upgrading, so we try do read from this table, as we counn't because of the key earlier groupIds = user2Groups.Select(x => x.UserGroupId).Distinct().ToList(); } // get groups // We wrap this in a try-catch, as this might throw errors when you try to login before having migrated your database Dictionary groups; try { sql = SqlContext.Sql() .Select() .From() .WhereIn(x => x.Id, groupIds); groups = Database.Fetch(sql) .ToDictionary(x => x.Id, x => x); } catch (Exception e) { Logger.LogDebug(e, "Couldn't get user groups. This should only happens doing the migration that add new columns to user groups"); sql = SqlContext.Sql() .Select(x => x.Id, x => x.Alias, x => x.StartContentId, x => x.StartMediaId) .From() .WhereIn(x => x.Id, groupIds); groups = Database.Fetch(sql) .ToDictionary(x => x.Id, x => x); } // get groups2apps sql = SqlContext.Sql() .Select() .From() .WhereIn(x => x.UserGroupId, groupIds); var groups2Apps = Database.Fetch(sql) .GroupBy(x => x.UserGroupId) .ToDictionary(x => x.Key, x => x); // get start nodes sql = SqlContext.Sql() .Select() .From() .WhereIn(x => x.UserId, userIds); List? startNodes = Database.Fetch(sql); // get groups2languages sql = SqlContext.Sql() .Select() .From() .WhereIn(x => x.UserGroupId, groupIds); Dictionary> groups2languages; try { groups2languages = Database.Fetch(sql) .GroupBy(x => x.UserGroupId) .ToDictionary(x => x.Key, x => x); } catch { // If we get an error, the table has not been made in the database yet, set the list to an empty one groups2languages = new Dictionary>(); } // get groups2permissions sql = SqlContext.Sql() .Select() .From() .WhereIn(x => x.UserGroupKey, groupKeys); Dictionary> groups2permissions; try { groups2permissions = Database.Fetch(sql) .GroupBy(x => x.UserGroupKey) .ToDictionary(x => x.Key, x => x); } catch { // If we get an error, the table has not been made in the database yet, set the list to an empty one groups2permissions = new Dictionary>(); } // get groups2granularPermissions sql = SqlContext.Sql() .Select() .From() .WhereIn(x => x.UserGroupKey, groupKeys); Dictionary> groups2GranularPermissions; try { groups2GranularPermissions = Database.Fetch(sql) .GroupBy(x => x.UserGroupKey) .ToDictionary(x => x.Key, x => x); } catch { // If we get an error, the table has not been made in the database yet, set the list to an empty one groups2GranularPermissions = new Dictionary>(); } // map groups foreach (User2UserGroupDto? user2Group in user2Groups) { if (groups.TryGetValue(user2Group.UserGroupId, out UserGroupDto? group)) { UserDto dto = xUsers == null ? dtos[0] : xUsers[user2Group.UserId]; dto.UserGroupDtos.Add(group); // user2group is distinct } } // map start nodes foreach (UserStartNodeDto? startNode in startNodes) { UserDto dto = xUsers == null ? dtos[0] : xUsers[startNode.UserId]; dto.UserStartNodeDtos.Add(startNode); // hashset = distinct } // map apps foreach (UserGroupDto? group in groups.Values) { if (groups2Apps.TryGetValue(group.Id, out IGrouping? list)) { group.UserGroup2AppDtos = list.ToList(); // groups2apps is distinct } } // map languages foreach (UserGroupDto group in groups.Values) { if (groups2languages.TryGetValue(group.Id, out IGrouping? list)) { group.UserGroup2LanguageDtos = list.ToList(); // groups2apps is distinct } } // map group permissions foreach (UserGroupDto? group in groups.Values) { if (groups2permissions.TryGetValue(group.Key, out IGrouping? list)) { group.UserGroup2PermissionDtos = list.ToList(); // groups2apps is distinct } } // map granular permissions foreach (UserGroupDto? group in groups.Values) { if (groups2GranularPermissions.TryGetValue(group.Key, out IGrouping? list)) { group.UserGroup2GranularPermissionDtos = list.ToList(); // groups2apps is distinct } } } #endregion #region Overrides of EntityRepositoryBase protected override Sql GetBaseQuery(bool isCount) { if (isCount) { return SqlContext.Sql() .SelectCount() .From(); } return SqlContext.Sql() .Select() .From(); } private static void AddGroupLeftJoin(Sql sql) => sql .LeftJoin() .On(left => left.UserId, right => right.Id) .LeftJoin() .On(left => left.Id, right => right.UserGroupId) .LeftJoin() .On(left => left.UserGroupId, right => right.Id) .LeftJoin() .On(left => left.UserId, right => right.Id); private Sql GetBaseQuery(string columns) => SqlContext.Sql() .Select(columns) .From(); protected override string GetBaseWhereClause() => $"{Constants.DatabaseSchema.Tables.User}.id = @id"; protected override IEnumerable GetDeleteClauses() { var list = new List { $"DELETE FROM {Constants.DatabaseSchema.Tables.UserLogin} WHERE userId = @id", $"DELETE FROM {Constants.DatabaseSchema.Tables.User2UserGroup} WHERE userId = @id", $"DELETE FROM {Constants.DatabaseSchema.Tables.User2NodeNotify} WHERE userId = @id", $"DELETE FROM {Constants.DatabaseSchema.Tables.User2ClientId} WHERE userId = @id", $"DELETE FROM {Constants.DatabaseSchema.Tables.UserStartNode} WHERE userId = @id", $"DELETE FROM {Constants.DatabaseSchema.Tables.ExternalLoginToken} WHERE externalLoginId = (SELECT id FROM {Constants.DatabaseSchema.Tables.ExternalLogin} WHERE userOrMemberKey = @key)", $"DELETE FROM {Constants.DatabaseSchema.Tables.ExternalLogin} WHERE userOrMemberKey = @key", $"DELETE FROM {Constants.DatabaseSchema.Tables.User} WHERE id = @id", }; return list; } protected override void PersistDeletedItem(IUser entity) { IEnumerable deletes = GetDeleteClauses(); foreach (var delete in deletes) { Database.Execute(delete, new { id = entity.Id, key = GetEntityId(entity) }); } entity.DeleteDate = DateTime.Now; } protected override void PersistNewItem(IUser entity) { entity.AddingEntity(); // ensure security stamp if missing if (entity.SecurityStamp.IsNullOrWhiteSpace()) { entity.SecurityStamp = Guid.NewGuid().ToString(); } UserDto userDto = UserFactory.BuildDto(entity); // check if we have a user config else use the default userDto.PasswordConfig = entity.PasswordConfiguration ?? DefaultPasswordConfigJson; var id = Convert.ToInt32(Database.Insert(userDto)); entity.Id = id; if (entity.IsPropertyDirty("StartContentIds")) { AddingOrUpdateStartNodes(entity, Enumerable.Empty(), UserStartNodeDto.StartNodeTypeValue.Content, entity.StartContentIds); } if (entity.IsPropertyDirty("StartMediaIds")) { AddingOrUpdateStartNodes(entity, Enumerable.Empty(), UserStartNodeDto.StartNodeTypeValue.Media, entity.StartMediaIds); } if (entity.IsPropertyDirty("Groups")) { // lookup all assigned List? assigned = entity.Groups == null || entity.Groups.Any() == false ? new List() : Database.Fetch( "SELECT * FROM umbracoUserGroup WHERE userGroupAlias IN (@aliases)", new { aliases = entity.Groups.Select(x => x.Alias) }); foreach (UserGroupDto? groupDto in assigned) { var dto = new User2UserGroupDto { UserGroupId = groupDto.Id, UserId = entity.Id }; Database.Insert(dto); } } entity.ResetDirtyProperties(); } protected override void PersistUpdatedItem(IUser entity) { // updates Modified date entity.UpdatingEntity(); // ensure security stamp if missing if (entity.SecurityStamp.IsNullOrWhiteSpace()) { entity.SecurityStamp = Guid.NewGuid().ToString(); } UserDto userDto = UserFactory.BuildDto(entity); // build list of columns to check for saving - we don't want to save the password if it hasn't changed! // list the columns to save, NOTE: would be nice to not have hard coded strings here but no real good way around that var colsToSave = new Dictionary { //TODO: Change these to constants + nameof {"userDisabled", "IsApproved"}, {"userNoConsole", "IsLockedOut"}, {"startStructureID", "StartContentId"}, {"startMediaID", "StartMediaId"}, {"userName", "Name"}, {"userLogin", "Username"}, {"userEmail", "Email"}, {"userLanguage", "Language"}, {"securityStampToken", "SecurityStamp"}, {"lastLockoutDate", "LastLockoutDate"}, {"lastPasswordChangeDate", "LastPasswordChangeDate"}, {"lastLoginDate", "LastLoginDate"}, {"failedLoginAttempts", "FailedPasswordAttempts"}, {"createDate", "CreateDate"}, {"updateDate", "UpdateDate"}, {"avatar", "Avatar"}, {"emailConfirmedDate", "EmailConfirmedDate"}, {"invitedDate", "InvitedDate"} }; // create list of properties that have changed var changedCols = colsToSave .Where(col => entity.IsPropertyDirty(col.Value)) .Select(col => col.Key) .ToList(); if (entity.IsPropertyDirty("SecurityStamp")) { changedCols.Add("securityStampToken"); } // DO NOT update the password if it has not changed or if it is null or empty if (entity.IsPropertyDirty("RawPasswordValue") && entity.RawPasswordValue.IsNullOrWhiteSpace() == false) { changedCols.Add("userPassword"); // If the security stamp hasn't already updated we need to force it if (entity.IsPropertyDirty("SecurityStamp") == false) { userDto.SecurityStampToken = entity.SecurityStamp = Guid.NewGuid().ToString(); changedCols.Add("securityStampToken"); } // check if we have a user config else use the default userDto.PasswordConfig = entity.PasswordConfiguration ?? DefaultPasswordConfigJson; changedCols.Add("passwordConfig"); } // If userlogin or the email has changed then need to reset security stamp if (changedCols.Contains("userLogin") || changedCols.Contains("userEmail")) { userDto.EmailConfirmedDate = null; changedCols.Add("emailConfirmedDate"); // If the security stamp hasn't already updated we need to force it if (entity.IsPropertyDirty("SecurityStamp") == false) { userDto.SecurityStampToken = entity.SecurityStamp = Guid.NewGuid().ToString(); changedCols.Add("securityStampToken"); } } //only update the changed cols if (changedCols.Count > 0) { Database.Update(userDto, changedCols); } if (entity.IsPropertyDirty("StartContentIds") || entity.IsPropertyDirty("StartMediaIds")) { List? assignedStartNodes = Database.Fetch( "SELECT * FROM umbracoUserStartNode WHERE userId = @userId", new { userId = entity.Id }); if (entity.IsPropertyDirty("StartContentIds")) { AddingOrUpdateStartNodes(entity, assignedStartNodes, UserStartNodeDto.StartNodeTypeValue.Content, entity.StartContentIds); } if (entity.IsPropertyDirty("StartMediaIds")) { AddingOrUpdateStartNodes(entity, assignedStartNodes, UserStartNodeDto.StartNodeTypeValue.Media, entity.StartMediaIds); } } if (entity.IsPropertyDirty("Groups")) { //lookup all assigned List? assigned = entity.Groups == null || entity.Groups.Any() == false ? new List() : Database.Fetch( "SELECT * FROM umbracoUserGroup WHERE userGroupAlias IN (@aliases)", new { aliases = entity.Groups.Select(x => x.Alias) }); //first delete all // TODO: We could do this a nicer way instead of "Nuke and Pave" Database.Delete("WHERE UserId = @UserId", new { UserId = entity.Id }); foreach (UserGroupDto? groupDto in assigned) { var dto = new User2UserGroupDto { UserGroupId = groupDto.Id, UserId = entity.Id }; Database.Insert(dto); } } entity.ResetDirtyProperties(); } private void AddingOrUpdateStartNodes(IEntity entity, IEnumerable current, UserStartNodeDto.StartNodeTypeValue startNodeType, int[]? entityStartIds) { if (entityStartIds is null) { return; } var assignedIds = current.Where(x => x.StartNodeType == (int)startNodeType).Select(x => x.StartNode).ToArray(); //remove the ones not assigned to the entity var toDelete = assignedIds.Except(entityStartIds).ToArray(); if (toDelete.Length > 0) { Database.Delete( "WHERE UserId = @UserId AND startNode IN (@startNodes)", new { UserId = entity.Id, startNodes = toDelete }); } //add the ones not currently in the db var toAdd = entityStartIds.Except(assignedIds).ToArray(); foreach (var i in toAdd) { var dto = new UserStartNodeDto { StartNode = i, StartNodeType = (int)startNodeType, UserId = entity.Id }; Database.Insert(dto); } } #endregion #region Implementation of IUserRepository public int GetCountByQuery(IQuery? query) { Sql sqlClause = GetBaseQuery("umbracoUser.id"); var translator = new SqlTranslator(sqlClause, query); Sql subquery = translator.Translate(); //get the COUNT base query Sql? sql = GetBaseQuery(true) .Append(new Sql("WHERE umbracoUser.id IN (" + subquery.SQL + ")", subquery.Arguments)); return Database.ExecuteScalar(sql); } protected override bool PerformExists(Guid key) { Sql sql = SqlContext.Sql() .SelectCount() .From() .Where(x => x.Key == key); return Database.ExecuteScalar(sql) > 0; } public bool Exists(string username) => ExistsByUserName(username); public bool ExistsByUserName(string username) { Sql sql = SqlContext.Sql() .SelectCount() .From() .Where(x => x.UserName == username); return Database.ExecuteScalar(sql) > 0; } // This is a bit hacky, as we're stealing some of the cache implementation, so we also can cache user by id // We do however need this, as all content have creatorId (as int) and thus when we index content // this gets called for each content item, and we need to cache the user to avoid a lot of db calls // TODO: Remove this once CreatorId gets migrated to a key. public IUser? Get(int id) { string cacheKey = RepositoryCacheKeys.GetKey(id); IUser? cachedUser = IsolatedCache.GetCacheItem(cacheKey); if (cachedUser is not null) { return cachedUser; } Sql sql = SqlContext.Sql() .Select() .From() .Where(x => x.Id == id); List? dtos = Database.Fetch(sql); if (dtos.Count == 0) { return null; } PerformGetReferencedDtos(dtos); IUser user = UserFactory.BuildEntity(_globalSettings, dtos[0], _permissionMappers); IsolatedCache.Insert(cacheKey, () => user, TimeSpan.FromMinutes(5), true); return user; } public bool ExistsByLogin(string login) { Sql sql = SqlContext.Sql() .SelectCount() .From() .Where(x => x.Login == login); return Database.ExecuteScalar(sql) > 0; } /// /// Gets a list of objects associated with a given group /// /// Id of group public IEnumerable GetAllInGroup(int groupId) => GetAllInOrNotInGroup(groupId, true); /// /// Gets a list of objects not associated with a given group /// /// Id of group public IEnumerable GetAllNotInGroup(int groupId) => GetAllInOrNotInGroup(groupId, false); private IEnumerable GetAllInOrNotInGroup(int groupId, bool include) { Sql sql = SqlContext.Sql() .Select() .From(); Sql inSql = SqlContext.Sql() .Select(x => x.UserId) .From() .Where(x => x.UserGroupId == groupId); if (include) { sql.WhereIn(x => x.Id, inSql); } else { sql.WhereNotIn(x => x.Id, inSql); } List? dtos = Database.Fetch(sql); //adds missing bits like content and media start nodes PerformGetReferencedDtos(dtos); return ConvertFromDtos(dtos); } /// /// Gets paged user results /// /// /// /// /// /// /// /// /// A filter to only include user that belong to these user groups /// /// /// A filter to only include users that do not belong to these user groups /// /// Optional parameter to filter by specified user state /// /// /// /// The query supplied will ONLY work with data specifically on the umbracoUser table because we are using NPoco paging /// (SQL paging) /// public IEnumerable GetPagedResultsByQuery(IQuery? query, long pageIndex, int pageSize, out long totalRecords, Expression> orderBy, Direction orderDirection = Direction.Ascending, string[]? includeUserGroups = null, string[]? excludeUserGroups = null, UserState[]? userState = null, IQuery? filter = null) { if (orderBy == null) { throw new ArgumentNullException(nameof(orderBy)); } Sql? filterSql = null; Tuple[]? customFilterWheres = filter?.GetWhereClauses().ToArray(); var hasCustomFilter = customFilterWheres != null && customFilterWheres.Length > 0; if (hasCustomFilter || (includeUserGroups != null && includeUserGroups.Length > 0) || (excludeUserGroups != null && excludeUserGroups.Length > 0) || (userState != null && userState.Length > 0 && userState.Contains(UserState.All) == false)) { filterSql = SqlContext.Sql(); } if (hasCustomFilter) { foreach (Tuple clause in customFilterWheres!) { filterSql?.Append($"AND ({clause.Item1})", clause.Item2); } } if (includeUserGroups != null && includeUserGroups.Length > 0) { const string subQuery = @"AND (umbracoUser.id IN (SELECT DISTINCT umbracoUser.id FROM umbracoUser INNER JOIN umbracoUser2UserGroup ON umbracoUser2UserGroup.userId = umbracoUser.id INNER JOIN umbracoUserGroup ON umbracoUserGroup.id = umbracoUser2UserGroup.userGroupId WHERE umbracoUserGroup.userGroupAlias IN (@userGroups)))"; filterSql?.Append(subQuery, new { userGroups = includeUserGroups }); } if (excludeUserGroups != null && excludeUserGroups.Length > 0) { const string subQuery = @"AND (umbracoUser.id NOT IN (SELECT DISTINCT umbracoUser.id FROM umbracoUser INNER JOIN umbracoUser2UserGroup ON umbracoUser2UserGroup.userId = umbracoUser.id INNER JOIN umbracoUserGroup ON umbracoUserGroup.id = umbracoUser2UserGroup.userGroupId WHERE umbracoUserGroup.userGroupAlias IN (@userGroups)))"; filterSql?.Append(subQuery, new { userGroups = excludeUserGroups }); } if (userState != null && userState.Length > 0) { //the "ALL" state doesn't require any filtering so we ignore that, if it exists in the list we don't do any filtering if (userState.Contains(UserState.All) == false) { var sb = new StringBuilder("("); var appended = false; if (userState.Contains(UserState.Active)) { sb.Append("(userDisabled = 0 AND userNoConsole = 0 AND lastLoginDate IS NOT NULL)"); appended = true; } if (userState.Contains(UserState.Inactive)) { if (appended) { sb.Append(" OR "); } sb.Append("(userDisabled = 0 AND userNoConsole = 0 AND lastLoginDate IS NULL)"); appended = true; } if (userState.Contains(UserState.Disabled)) { if (appended) { sb.Append(" OR "); } sb.Append("(userDisabled = 1)"); appended = true; } if (userState.Contains(UserState.LockedOut)) { if (appended) { sb.Append(" OR "); } sb.Append("(userNoConsole = 1)"); appended = true; } if (userState.Contains(UserState.Invited)) { if (appended) { sb.Append(" OR "); } sb.Append("(lastLoginDate IS NULL AND userDisabled = 1 AND invitedDate IS NOT NULL)"); appended = true; } sb.Append(")"); filterSql?.Append("AND " + sb); } } // create base query Sql sql = SqlContext.Sql() .Select() .From(); // apply query if (query != null) { sql = new SqlTranslator(sql, query).Translate(); } // get sorted and filtered sql Sql sqlNodeIdsWithSort = ApplySort(ApplyFilter(sql, filterSql, query != null), orderBy, orderDirection); // get a page of results and total count Page? pagedResult = Database.Page(pageIndex + 1, pageSize, sqlNodeIdsWithSort); totalRecords = Convert.ToInt32(pagedResult.TotalItems); // map references PerformGetReferencedDtos(pagedResult.Items); return pagedResult.Items.Select(x => UserFactory.BuildEntity(_globalSettings, x, _permissionMappers)); } public IEnumerable GetAllClientIds() => Database.Fetch(SqlContext.Sql() .Select(d => d.ClientId) .From()); public IEnumerable GetClientIds(int id) => Database.Fetch(SqlContext.Sql() .Select(d => d.ClientId) .From() .Where(d => d.UserId == id)); public void AddClientId(int id, string clientId) => Database.Insert(new User2ClientIdDto { UserId = id, ClientId = clientId }); public bool RemoveClientId(int id, string clientId) => Database.Delete(SqlContext.Sql() .Where(d => d.UserId == id && d.ClientId == clientId)) > 0; public IUser? GetByClientId(string clientId) { var userId = Database.ExecuteScalar( SqlContext.Sql() .Select(d => d.UserId) .From() .Where(d => d.ClientId == clientId)); if (userId == 0) { return null; } return Get(userId); } private Sql ApplyFilter(Sql sql, Sql? filterSql, bool hasWhereClause) { if (filterSql == null) { return sql; } //ensure we don't append a WHERE if there is already one var args = filterSql.Arguments; var sqlFilter = hasWhereClause ? filterSql.SQL : " WHERE " + filterSql.SQL.TrimStart("AND "); sql.Append(SqlContext.Sql(sqlFilter, args)); return sql; } private Sql ApplySort(Sql sql, Expression>? orderBy, Direction orderDirection) { if (orderBy == null) { return sql; } MemberInfo? expressionMember = ExpressionHelper.GetMemberInfo(orderBy); BaseMapper mapper = _mapperCollection[typeof(IUser)]; var mappedField = mapper.Map(expressionMember?.Name); if (mappedField.IsNullOrWhiteSpace()) { throw new ArgumentException("Could not find a mapping for the column specified in the orderBy clause"); } // beware! NPoco paging code parses the query to isolate the ORDER BY fragment, // using a regex that wants "([\w\.\[\]\(\)\s""`,]+)" - meaning that anything // else in orderBy is going to break NPoco / not be detected // beware! NPoco paging code (in PagingHelper) collapses everything [foo].[bar] // to [bar] only, so we MUST use aliases, cannot use [table].[field] // beware! pre-2012 SqlServer is using a convoluted syntax for paging, which // includes "SELECT ROW_NUMBER() OVER (ORDER BY ...) poco_rn FROM SELECT (...", // so anything added here MUST also be part of the inner SELECT statement, ie // the original statement, AND must be using the proper alias, as the inner SELECT // will hide the original table.field names entirely var orderByField = sql.GetAliasedField(mappedField); if (orderDirection == Direction.Ascending) { sql.OrderBy(orderByField); } else { sql.OrderByDescending(orderByField); } return sql; } /// public void InvalidateSessionsForRemovedProviders(IEnumerable currentLoginProviders) { // Get all the user keys associated with the removed providers. Sql idsQuery = SqlContext.Sql() .Select(x => x.UserOrMemberKey) .From() .Where(x => !x.LoginProvider.StartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix)) // Only invalidate sessions relating to backoffice users, not members. .WhereNotIn(x => x.LoginProvider, currentLoginProviders); List userKeysAssociatedWithRemovedProviders = Database.Fetch(idsQuery); if (userKeysAssociatedWithRemovedProviders.Count == 0) { return; } // Invalidate the security stamps on the users associated with the removed providers. Sql updateSecurityStampsQuery = Sql() .Update(u => u.Set(x => x.SecurityStampToken, "0".PadLeft(32, '0'))) .WhereIn(x => x.Key, userKeysAssociatedWithRemovedProviders); Database.Execute(updateSecurityStampsQuery); // Delete the OpenIddict tokens for the users associated with the removed providers. // The following is safe from SQL injection as we are dealing with GUIDs, not strings. var userKeysForInClause = string.Join("','", userKeysAssociatedWithRemovedProviders.Select(x => x.ToString())); Database.Execute("DELETE FROM umbracoOpenIddictTokens WHERE Subject IN ('" + userKeysForInClause + "')"); } #endregion }