using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.Models.Entities; using Umbraco.Core.Models.Identity; using Umbraco.Core.Models.Membership; namespace Umbraco.Web.Models.Identity { public class BackOfficeIdentityUser : IdentityUser, IdentityUserClaim>, IRememberBeingDirty { private string _email; private string _userName; private int _id; private bool _hasIdentity; private DateTime? _lastLoginDateUtc; private bool _emailConfirmed; private string _name; private int _accessFailedCount; private string _passwordHash; private string _culture; private ObservableCollection _logins; private Lazy> _getLogins; private IReadOnlyUserGroup[] _groups; private string[] _allowedSections; private int[] _startMediaIds; private int[] _startContentIds; private DateTime? _lastPasswordChangeDateUtc; /// /// Used to construct a new instance without an identity /// /// /// This is allowed to be null (but would need to be filled in if trying to persist this instance) /// /// public static BackOfficeIdentityUser CreateNew(IGlobalSettings globalSettings, string username, string email, string culture) { if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(username)); if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(culture)); var user = new BackOfficeIdentityUser(globalSettings, Array.Empty()); user.DisableChangeTracking(); user._userName = username; user._email = email; //we are setting minvalue here because the default is "0" which is the id of the admin user //which we cannot allow because the admin user will always exist user._id = int.MinValue; user._hasIdentity = false; user._culture = culture; user.EnableChangeTracking(); return user; } private BackOfficeIdentityUser(IGlobalSettings globalSettings, IReadOnlyUserGroup[] groups) { _startMediaIds = Array.Empty(); _startContentIds = Array.Empty(); _allowedSections = Array.Empty(); _culture = globalSettings.DefaultUILanguage; // must initialize before setting groups _roles = new ObservableCollection>(); _roles.CollectionChanged += _roles_CollectionChanged; // use the property setters - they do more than just setting a field Groups = groups; } /// /// Creates an existing user with the specified groups /// /// /// /// public BackOfficeIdentityUser(IGlobalSettings globalSettings, int userId, IEnumerable groups) : this(globalSettings, groups.ToArray()) { // use the property setters - they do more than just setting a field Id = userId; } /// /// Returns true if an Id has been set on this object this will be false if the object is new and not persisted to the database /// public bool HasIdentity => _hasIdentity; public int[] CalculatedMediaStartNodeIds { get; internal set; } public int[] CalculatedContentStartNodeIds { get; internal set; } public override int Id { get => _id; set { _id = value; _hasIdentity = true; } } /// /// Override Email so we can track changes to it /// public override string Email { get => _email; set => _beingDirty.SetPropertyValueAndDetectChanges(value, ref _email, nameof(Email)); } /// /// Override UserName so we can track changes to it /// public override string UserName { get => _userName; set => _beingDirty.SetPropertyValueAndDetectChanges(value, ref _userName, nameof(UserName)); } /// /// LastPasswordChangeDateUtc so we can track changes to it /// public override DateTime? LastPasswordChangeDateUtc { get { return _lastPasswordChangeDateUtc; } set { _beingDirty.SetPropertyValueAndDetectChanges(value, ref _lastPasswordChangeDateUtc, nameof(LastPasswordChangeDateUtc)); } } /// /// Override LastLoginDateUtc so we can track changes to it /// public override DateTime? LastLoginDateUtc { get => _lastLoginDateUtc; set => _beingDirty.SetPropertyValueAndDetectChanges(value, ref _lastLoginDateUtc, nameof(LastLoginDateUtc)); } /// /// Override EmailConfirmed so we can track changes to it /// public override bool EmailConfirmed { get => _emailConfirmed; set => _beingDirty.SetPropertyValueAndDetectChanges(value, ref _emailConfirmed, nameof(EmailConfirmed)); } /// /// Gets/sets the user's real name /// public string Name { get => _name; set => _beingDirty.SetPropertyValueAndDetectChanges(value, ref _name, nameof(Name)); } /// /// Override AccessFailedCount so we can track changes to it /// public override int AccessFailedCount { get => _accessFailedCount; set => _beingDirty.SetPropertyValueAndDetectChanges(value, ref _accessFailedCount, nameof(AccessFailedCount)); } /// /// Override PasswordHash so we can track changes to it /// public override string PasswordHash { get => _passwordHash; set => _beingDirty.SetPropertyValueAndDetectChanges(value, ref _passwordHash, nameof(PasswordHash)); } /// /// Content start nodes assigned to the User (not ones assigned to the user's groups) /// public int[] StartContentIds { get => _startContentIds; set { if (value == null) value = new int[0]; _beingDirty.SetPropertyValueAndDetectChanges(value, ref _startContentIds, nameof(StartContentIds), StartIdsComparer); } } /// /// Media start nodes assigned to the User (not ones assigned to the user's groups) /// public int[] StartMediaIds { get => _startMediaIds; set { if (value == null) value = new int[0]; _beingDirty.SetPropertyValueAndDetectChanges(value, ref _startMediaIds, nameof(StartMediaIds), StartIdsComparer); } } /// /// This is a readonly list of the user's allowed sections which are based on it's user groups /// public string[] AllowedSections { get { return _allowedSections ?? (_allowedSections = _groups.SelectMany(x => x.AllowedSections).Distinct().ToArray()); } } public string Culture { get => _culture; set => _beingDirty.SetPropertyValueAndDetectChanges(value, ref _culture, nameof(Culture)); } public IReadOnlyUserGroup[] Groups { get => _groups; set { //so they recalculate _allowedSections = null; _groups = value; //now clear all roles and re-add them _roles.CollectionChanged -= _roles_CollectionChanged; _roles.Clear(); foreach (var identityUserRole in _groups.Select(x => new IdentityUserRole { RoleId = x.Alias, UserId = Id.ToString() })) { _roles.Add(identityUserRole); } _roles.CollectionChanged += _roles_CollectionChanged; _beingDirty.SetPropertyValueAndDetectChanges(value, ref _groups, nameof(Groups), GroupsComparer); } } /// /// Lockout is always enabled /// public override bool LockoutEnabled { get { return true; } set { //do nothing } } /// /// Based on the user's lockout end date, this will determine if they are locked out /// internal bool IsLockedOut { get { var isLocked = LockoutEndDateUtc.HasValue && LockoutEndDateUtc.Value.ToLocalTime() >= DateTime.Now; return isLocked; } } /// /// This is a 1:1 mapping with IUser.IsApproved /// internal bool IsApproved { get; set; } /// /// Overridden to make the retrieval lazy /// public override ICollection Logins { get { if (_getLogins != null && _getLogins.IsValueCreated == false) { _logins = new ObservableCollection(); foreach (var l in _getLogins.Value) { _logins.Add(l); } //now assign events _logins.CollectionChanged += Logins_CollectionChanged; } return _logins; } } void Logins_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { _beingDirty.OnPropertyChanged(nameof(Logins)); } private void _roles_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { _beingDirty.OnPropertyChanged(nameof(Roles)); } private readonly ObservableCollection> _roles; /// /// helper method to easily add a role without having to deal with IdentityUserRole{T} /// /// /// /// Adding a role this way will not reflect on the user's group's collection or it's allowed sections until the user is persisted /// public void AddRole(string role) { Roles.Add(new IdentityUserRole { UserId = Id.ToString(), RoleId = role }); } /// /// Override Roles because the value of these are the user's group aliases /// public override ICollection> Roles => _roles; /// /// Used to set a lazy call back to populate the user's Login list /// /// public void SetLoginsCallback(Lazy> callback) { _getLogins = callback ?? throw new ArgumentNullException(nameof(callback)); } #region BeingDirty private readonly BeingDirty _beingDirty = new BeingDirty(); /// public bool IsDirty() { return _beingDirty.IsDirty(); } /// public bool IsPropertyDirty(string propName) { return _beingDirty.IsPropertyDirty(propName); } /// public IEnumerable GetDirtyProperties() { return _beingDirty.GetDirtyProperties(); } /// public void ResetDirtyProperties() { _beingDirty.ResetDirtyProperties(); } /// public bool WasDirty() { return _beingDirty.WasDirty(); } /// public bool WasPropertyDirty(string propertyName) { return _beingDirty.WasPropertyDirty(propertyName); } /// public void ResetWereDirtyProperties() { _beingDirty.ResetWereDirtyProperties(); } /// public void ResetDirtyProperties(bool rememberDirty) { _beingDirty.ResetDirtyProperties(rememberDirty); } /// public IEnumerable GetWereDirtyProperties() => _beingDirty.GetWereDirtyProperties(); /// /// Disables change tracking. /// public void DisableChangeTracking() { _beingDirty.DisableChangeTracking(); } /// /// Enables change tracking. /// public void EnableChangeTracking() { _beingDirty.EnableChangeTracking(); } public event PropertyChangedEventHandler PropertyChanged { add { _beingDirty.PropertyChanged += value; } remove { _beingDirty.PropertyChanged -= value; } } #endregion //Custom comparer for enumerables private static readonly DelegateEqualityComparer GroupsComparer = new DelegateEqualityComparer( (groups, enumerable) => groups.Select(x => x.Alias).UnsortedSequenceEqual(enumerable.Select(x => x.Alias)), groups => groups.GetHashCode()); private static readonly DelegateEqualityComparer StartIdsComparer = new DelegateEqualityComparer( (groups, enumerable) => groups.UnsortedSequenceEqual(enumerable), groups => groups.GetHashCode()); } }