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
}