diff --git a/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs b/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs index 1a4e7ae1a9..cc555afe55 100644 --- a/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs +++ b/src/Umbraco.Core/Collections/CompositeTypeTypeKey.cs @@ -10,7 +10,8 @@ namespace Umbraco.Core.Collections /// /// Initializes a new instance of the struct. /// - public CompositeTypeTypeKey(Type type1, Type type2) : this() + public CompositeTypeTypeKey(Type type1, Type type2) + : this() { Type1 = type1; Type2 = type2; @@ -19,12 +20,12 @@ namespace Umbraco.Core.Collections /// /// Gets the first type. /// - public Type Type1 { get; private set; } + public Type Type1 { get; } /// /// Gets the second type. /// - public Type Type2 { get; private set; } + public Type Type2 { get; } /// public bool Equals(CompositeTypeTypeKey other) @@ -35,7 +36,7 @@ namespace Umbraco.Core.Collections /// public override bool Equals(object obj) { - var other = obj is CompositeTypeTypeKey ? (CompositeTypeTypeKey)obj : default(CompositeTypeTypeKey); + var other = obj is CompositeTypeTypeKey key ? key : default; return Type1 == other.Type1 && Type2 == other.Type2; } diff --git a/src/Umbraco.Core/Events/EventMessages.cs b/src/Umbraco.Core/Events/EventMessages.cs index aac0d07e85..2df1911249 100644 --- a/src/Umbraco.Core/Events/EventMessages.cs +++ b/src/Umbraco.Core/Events/EventMessages.cs @@ -5,7 +5,7 @@ namespace Umbraco.Core.Events /// /// Event messages collection /// - public sealed class EventMessages : DisposableObject + public sealed class EventMessages : DisposableObjectSlim { private readonly List _msgs = new List(); diff --git a/src/Umbraco.Core/Events/ExportedMemberEventArgs.cs b/src/Umbraco.Core/Events/ExportedMemberEventArgs.cs new file mode 100644 index 0000000000..9c91f3e5bd --- /dev/null +++ b/src/Umbraco.Core/Events/ExportedMemberEventArgs.cs @@ -0,0 +1,18 @@ +using System; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Membership; + +namespace Umbraco.Core.Events +{ + internal class ExportedMemberEventArgs : EventArgs + { + public IMember Member { get; } + public MemberExportModel Exported { get; } + + public ExportedMemberEventArgs(IMember member, MemberExportModel exported) + { + Member = member; + Exported = exported; + } + } +} diff --git a/src/Umbraco.Core/Events/ImportPackageEventArgs.cs b/src/Umbraco.Core/Events/ImportPackageEventArgs.cs index 6ad7b52806..7536b43e93 100644 --- a/src/Umbraco.Core/Events/ImportPackageEventArgs.cs +++ b/src/Umbraco.Core/Events/ImportPackageEventArgs.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using Umbraco.Core.Models.Packaging; namespace Umbraco.Core.Events @@ -8,17 +9,26 @@ namespace Umbraco.Core.Events { private readonly MetaData _packageMetaData; + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Use the overload specifying packageMetaData instead")] public ImportPackageEventArgs(TEntity eventObject, bool canCancel) : base(new[] { eventObject }, canCancel) { } - public ImportPackageEventArgs(TEntity eventObject, MetaData packageMetaData) - : base(new[] { eventObject }) + public ImportPackageEventArgs(TEntity eventObject, MetaData packageMetaData, bool canCancel) + : base(new[] { eventObject }, canCancel) { + if (packageMetaData == null) throw new ArgumentNullException("packageMetaData"); _packageMetaData = packageMetaData; } + public ImportPackageEventArgs(TEntity eventObject, MetaData packageMetaData) + : this(eventObject, packageMetaData, true) + { + + } + public MetaData PackageMetaData { get { return _packageMetaData; } diff --git a/src/Umbraco.Core/Events/QueuingEventDispatcherBase.cs b/src/Umbraco.Core/Events/QueuingEventDispatcherBase.cs index 2cc7046078..0283ac372e 100644 --- a/src/Umbraco.Core/Events/QueuingEventDispatcherBase.cs +++ b/src/Umbraco.Core/Events/QueuingEventDispatcherBase.cs @@ -78,255 +78,256 @@ namespace Umbraco.Core.Events if (_events == null) return Enumerable.Empty(); + IReadOnlyList events; switch (filter) { case EventDefinitionFilter.All: - return FilterSupersededAndUpdateToLatestEntity(_events); + events = _events; + break; case EventDefinitionFilter.FirstIn: var l1 = new OrderedHashSet(); foreach (var e in _events) l1.Add(e); - return FilterSupersededAndUpdateToLatestEntity(l1); + events = l1; + break; case EventDefinitionFilter.LastIn: var l2 = new OrderedHashSet(keepOldest: false); foreach (var e in _events) l2.Add(e); - return FilterSupersededAndUpdateToLatestEntity(l2); + events = l2; + break; default: - throw new ArgumentOutOfRangeException(nameof(filter), filter, null); + throw new ArgumentOutOfRangeException("filter", filter, null); } + + return FilterSupersededAndUpdateToLatestEntity(events); } - private class EventDefinitionTypeData + private class EventDefinitionInfos { public IEventDefinition EventDefinition { get; set; } - public Type EventArgType { get; set; } - public SupersedeEventAttribute[] SupersedeAttributes { get; set; } + public Type[] SupersedeTypes { get; set; } } - /// - /// This will iterate over the events (latest first) and filter out any events or entities in event args that are included - /// in more recent events that Supersede previous ones. For example, If an Entity has been Saved and then Deleted, we don't want - /// to raise the Saved event (well actually we just don't want to include it in the args for that saved event) - /// - /// - /// - private static IEnumerable FilterSupersededAndUpdateToLatestEntity(IReadOnlyList events) + // fixme + // this is way too convoluted, the superceede attribute is used only on DeleteEventargs to specify + // that it superceeds save, publish, move and copy - BUT - publish event args is also used for + // unpublishing and should NOT be superceeded - so really it should not be managed at event args + // level but at event level + // + // what we want is: + // if an entity is deleted, then all Saved, Moved, Copied, Published events prior to this should + // not trigger for the entity - and even though, does it make any sense? making a copy of an entity + // should ... trigger? + // + // not going to refactor it all - we probably want to *always* trigger event but tell people that + // due to scopes, they should not expected eg a saved entity to still be around - however, now, + // going to write a ugly condition to deal with U4-10764 + + // iterates over the events (latest first) and filter out any events or entities in event args that are included + // in more recent events that Supersede previous ones. For example, If an Entity has been Saved and then Deleted, we don't want + // to raise the Saved event (well actually we just don't want to include it in the args for that saved event) + internal static IEnumerable FilterSupersededAndUpdateToLatestEntity(IReadOnlyList events) { - //used to keep the 'latest' entity and associated event definition data - var allEntities = new List>(); - - //tracks all CancellableObjectEventArgs instances in the events which is the only type of args we can work with - var cancelableArgs = new List(); + // keeps the 'latest' entity and associated event data + var entities = new List>(); + // collects the event definitions + // collects the arguments in result, that require their entities to be updated var result = new List(); + var resultArgs = new List(); - //This will eagerly load all of the event arg types and their attributes so we don't have to continuously look this data up - var allArgTypesWithAttributes = events.Select(x => x.Args.GetType()) + // eagerly fetch superceeded arg types for each arg type + var argTypeSuperceeding = events.Select(x => x.Args.GetType()) .Distinct() - .ToDictionary(x => x, x => x.GetCustomAttributes(false).ToArray()); + .ToDictionary(x => x, x => x.GetCustomAttributes(false).Select(y => y.SupersededEventArgsType).ToArray()); - //Iterate all events and collect the actual entities in them and relates them to their corresponding EventDefinitionTypeData - //we'll process the list in reverse because events are added in the order they are raised and we want to filter out - //any entities from event args that are not longer relevant - //(i.e. if an item is Deleted after it's Saved, we won't include the item in the Saved args) + // iterate over all events and filter + // + // process the list in reverse, because events are added in the order they are raised and we want to keep + // the latest (most recent) entities and filter out what is not relevant anymore (too old), eg if an entity + // is Deleted after being Saved, we want to filter out the Saved event for (var index = events.Count - 1; index >= 0; index--) { - var eventDefinition = events[index]; + var def = events[index]; - var argType = eventDefinition.Args.GetType(); - var attributes = allArgTypesWithAttributes[eventDefinition.Args.GetType()]; - - var meta = new EventDefinitionTypeData + var infos = new EventDefinitionInfos { - EventDefinition = eventDefinition, - EventArgType = argType, - SupersedeAttributes = attributes + EventDefinition = def, + SupersedeTypes = argTypeSuperceeding[def.Args.GetType()] }; - var args = eventDefinition.Args as CancellableObjectEventArgs; - if (args != null) + var args = def.Args as CancellableObjectEventArgs; + if (args == null) { - var list = TypeHelper.CreateGenericEnumerableFromObject(args.EventObject); - - if (list == null) + // not a cancellable event arg, include event definition in result + result.Add(def); + } + else + { + // event object can either be a single object or an enumerable of objects + // try to get as an enumerable, get null if it's not + var eventObjects = TypeHelper.CreateGenericEnumerableFromObject(args.EventObject); + if (eventObjects == null) { - //extract the event object - var obj = args.EventObject as IEntity; - if (obj != null) + // single object, cast as an IEntity + // if cannot cast, cannot filter, nothing - just include event definition in result + var eventEntity = args.EventObject as IEntity; + if (eventEntity == null) { - //Now check if this entity already exists in other event args that supersede this current event arg type - if (IsFiltered(obj, meta, allEntities) == false) - { - //if it's not filtered we can adde these args to the response - cancelableArgs.Add(args); - result.Add(eventDefinition); - //track the entity - allEntities.Add(Tuple.Create(obj, meta)); - } + result.Add(def); + continue; } - else + + // look for this entity in superceding event args + // found = must be removed (ie not added), else track + if (IsSuperceeded(eventEntity, infos, entities) == false) { - //Can't retrieve the entity so cant' filter or inspect, just add to the output - result.Add(eventDefinition); + // track + entities.Add(Tuple.Create(eventEntity, infos)); + + // track result arguments + // include event definition in result + resultArgs.Add(args); + result.Add(def); } } else { + // enumerable of objects var toRemove = new List(); - foreach (var entity in list) + foreach (var eventObject in eventObjects) { - //extract the event object - var obj = entity as IEntity; - if (obj != null) - { - //Now check if this entity already exists in other event args that supersede this current event arg type - if (IsFiltered(obj, meta, allEntities)) - { - //track it to be removed - toRemove.Add(obj); - } - else - { - //track the entity, it's not filtered - allEntities.Add(Tuple.Create(obj, meta)); - } - } + // extract the event object, cast as an IEntity + // if cannot cast, cannot filter, nothing to do - just leave it in the list & continue + var eventEntity = eventObject as IEntity; + if (eventEntity == null) + continue; + + // look for this entity in superceding event args + // found = must be removed, else track + if (IsSuperceeded(eventEntity, infos, entities)) + toRemove.Add(eventEntity); else - { - //we don't need to do anything here, we can't cast to IEntity so we cannot filter, so it will just remain in the list - } + entities.Add(Tuple.Create(eventEntity, infos)); } - //remove anything that has been filtered + // remove superceded entities foreach (var entity in toRemove) - { - list.Remove(entity); - } + eventObjects.Remove(entity); - //track the event and include in the response if there's still entities remaining in the list - if (list.Count > 0) + // if there are still entities in the list, keep the event definition + if (eventObjects.Count > 0) { if (toRemove.Count > 0) { - //re-assign if the items have changed - args.EventObject = list; + // re-assign if changed + args.EventObject = eventObjects; } - cancelableArgs.Add(args); - result.Add(eventDefinition); + + // track result arguments + // include event definition in result + resultArgs.Add(args); + result.Add(def); } } } - else - { - //it's not a cancelable event arg so we just include it in the result - result.Add(eventDefinition); - } } - //Now we'll deal with ensuring that only the latest(non stale) entities are used throughout all event args - UpdateToLatestEntities(allEntities, cancelableArgs); + // go over all args in result, and update them with the latest instanceof each entity + UpdateToLatestEntities(entities, resultArgs); - //we need to reverse the result since we've been adding by latest added events first! + // reverse, since we processed the list in reverse result.Reverse(); return result; } - private static void UpdateToLatestEntities(IEnumerable> allEntities, IEnumerable cancelableArgs) + // edits event args to use the latest instance of each entity + private static void UpdateToLatestEntities(IEnumerable> entities, IEnumerable args) { - //Now we'll deal with ensuring that only the latest(non stale) entities are used throughout all event args - + // get the latest entities + // ordered hash set + keepOldest will keep the latest inserted entity (in case of duplicates) var latestEntities = new OrderedHashSet(keepOldest: true); - foreach (var entity in allEntities.OrderByDescending(entity => entity.Item1.UpdateDate)) - { + foreach (var entity in entities.OrderByDescending(entity => entity.Item1.UpdateDate)) latestEntities.Add(entity.Item1); - } - foreach (var args in cancelableArgs) + foreach (var arg in args) { - var list = TypeHelper.CreateGenericEnumerableFromObject(args.EventObject); - if (list == null) + // event object can either be a single object or an enumerable of objects + // try to get as an enumerable, get null if it's not + var eventObjects = TypeHelper.CreateGenericEnumerableFromObject(arg.EventObject); + if (eventObjects == null) { - //try to find the args entity in the latest entity - based on the equality operators, this will - //match by Id since that is the default equality checker for IEntity. If one is found, than it is - //the most recent entity instance so update the args with that instance so we don't emit a stale instance. - var foundEntity = latestEntities.FirstOrDefault(x => Equals(x, args.EventObject)); + // single object + // look for a more recent entity for that object, and replace if any + // works by "equalling" entities ie the more recent one "equals" this one (though different object) + var foundEntity = latestEntities.FirstOrDefault(x => Equals(x, arg.EventObject)); if (foundEntity != null) - { - args.EventObject = foundEntity; - } + arg.EventObject = foundEntity; } else { + // enumerable of objects + // same as above but for each object var updated = false; - - for (int i = 0; i < list.Count; i++) + for (var i = 0; i < eventObjects.Count; i++) { - //try to find the args entity in the latest entity - based on the equality operators, this will - //match by Id since that is the default equality checker for IEntity. If one is found, than it is - //the most recent entity instance so update the args with that instance so we don't emit a stale instance. - var foundEntity = latestEntities.FirstOrDefault(x => Equals(x, list[i])); - if (foundEntity != null) - { - list[i] = foundEntity; - updated = true; - } + var foundEntity = latestEntities.FirstOrDefault(x => Equals(x, eventObjects[i])); + if (foundEntity == null) continue; + eventObjects[i] = foundEntity; + updated = true; } if (updated) - { - args.EventObject = list; - } + arg.EventObject = eventObjects; } } } - /// - /// This will check against all of the processed entity/events (allEntities) to see if this entity already exists in - /// event args that supersede the event args being passed in and if so returns true. - /// - /// - /// - /// - /// - private static bool IsFiltered( - IEntity entity, - EventDefinitionTypeData eventDef, - List> allEntities) + // determines if a given entity, appearing in a given event definition, should be filtered out, + // considering the entities that have already been visited - an entity is filtered out if it + // appears in another even definition, which superceedes this event definition. + private static bool IsSuperceeded(IEntity entity, EventDefinitionInfos infos, List> entities) { - var argType = eventDef.EventDefinition.Args.GetType(); + //var argType = meta.EventArgsType; + var argType = infos.EventDefinition.Args.GetType(); - //check if the entity is found in any processed event data that could possible supersede this one - var foundByEntity = allEntities - .Where(x => x.Item2.SupersedeAttributes.Length > 0 - //if it's the same arg type than it cannot supersede - && x.Item2.EventArgType != argType - && Equals(x.Item1, entity)) + // look for other instances of the same entity, coming from an event args that supercedes other event args, + // ie is marked with the attribute, and is not this event args (cannot supersede itself) + var superceeding = entities + .Where(x => x.Item2.SupersedeTypes.Length > 0 // has the attribute + && x.Item2.EventDefinition.Args.GetType() != argType // is not the same + && Equals(x.Item1, entity)) // same entity .ToArray(); - //no args have been processed with this entity so it should not be filtered - if (foundByEntity.Length == 0) + // first time we see this entity = not filtered + if (superceeding.Length == 0) return false; + // fixme see notes above + // delete event args does NOT superceedes 'unpublished' event + if (argType.IsGenericType && argType.GetGenericTypeDefinition() == typeof(PublishEventArgs<>) && infos.EventDefinition.EventName == "UnPublished") + return false; + + // found occurences, need to determine if this event args is superceded if (argType.IsGenericType) { - var supercededBy = foundByEntity - .FirstOrDefault(x => - x.Item2.SupersedeAttributes.Any(y => - //if the attribute type is a generic type def then compare with the generic type def of the event arg - (y.SupersededEventArgsType.IsGenericTypeDefinition && y.SupersededEventArgsType == argType.GetGenericTypeDefinition()) - //if the attribute type is not a generic type def then compare with the normal type of the event arg - || (y.SupersededEventArgsType.IsGenericTypeDefinition == false && y.SupersededEventArgsType == argType))); + // generic, must compare type arguments + var supercededBy = superceeding.FirstOrDefault(x => + x.Item2.SupersedeTypes.Any(y => + // superceeding a generic type which has the same generic type definition + // fixme no matter the generic type parameters? could be different? + y.IsGenericTypeDefinition && y == argType.GetGenericTypeDefinition() + // or superceeding a non-generic type which is ... fixme how is this ever possible? argType *is* generic? + || y.IsGenericTypeDefinition == false && y == argType)); return supercededBy != null; } else { - var supercededBy = foundByEntity - .FirstOrDefault(x => - x.Item2.SupersedeAttributes.Any(y => - //since the event arg type is not a generic type, then we just compare type 1:1 - y.SupersededEventArgsType == argType)); + // non-generic, can compare types 1:1 + var supercededBy = superceeding.FirstOrDefault(x => + x.Item2.SupersedeTypes.Any(y => y == argType)); return supercededBy != null; } } diff --git a/src/Umbraco.Core/Events/RolesEventArgs.cs b/src/Umbraco.Core/Events/RolesEventArgs.cs new file mode 100644 index 0000000000..3104412f99 --- /dev/null +++ b/src/Umbraco.Core/Events/RolesEventArgs.cs @@ -0,0 +1,16 @@ +using System; + +namespace Umbraco.Core.Events +{ + public class RolesEventArgs : EventArgs + { + public RolesEventArgs(int[] memberIds, string[] roles) + { + MemberIds = memberIds; + Roles = roles; + } + + public int[] MemberIds { get; set; } + public string[] Roles { get; set; } + } +} diff --git a/src/Umbraco.Core/Events/UserGroupWithUsers.cs b/src/Umbraco.Core/Events/UserGroupWithUsers.cs new file mode 100644 index 0000000000..b69650d33f --- /dev/null +++ b/src/Umbraco.Core/Events/UserGroupWithUsers.cs @@ -0,0 +1,19 @@ +using System; +using Umbraco.Core.Models.Membership; + +namespace Umbraco.Core.Events +{ + internal class UserGroupWithUsers + { + public UserGroupWithUsers(IUserGroup userGroup, IUser[] addedUsers, IUser[] removedUsers) + { + UserGroup = userGroup; + AddedUsers = addedUsers; + RemovedUsers = removedUsers; + } + + public IUserGroup UserGroup { get; } + public IUser[] AddedUsers { get; } + public IUser[] RemovedUsers { get; } + } +} diff --git a/src/Umbraco.Core/Logging/RollingFileCleanupAppender.cs b/src/Umbraco.Core/Logging/RollingFileCleanupAppender.cs new file mode 100644 index 0000000000..6be2552296 --- /dev/null +++ b/src/Umbraco.Core/Logging/RollingFileCleanupAppender.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using log4net.Appender; +using log4net.Util; + +namespace Umbraco.Core.Logging +{ + /// + /// This class will do the exact same thing as the RollingFileAppender that comes from log4net + /// With the extension, that it is able to do automatic cleanup of the logfiles in the directory where logging happens + /// + /// By specifying the properties MaxLogFileDays and BaseFilePattern, the files will automaticly get deleted when + /// the logger is configured(typically when the app starts). To utilize this appender swap out the type of the rollingFile appender + /// that ships with Umbraco, to be Umbraco.Core.Logging.RollingFileCleanupAppender, and add the maxLogFileDays and baseFilePattern elements + /// to the configuration i.e.: + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public class RollingFileCleanupAppender : RollingFileAppender + { + public int MaxLogFileDays { get; set; } + public string BaseFilePattern { get; set; } + + /// + /// This override will delete logs older than the specified amount of days + /// + /// + /// + protected override void OpenFile(string fileName, bool append) + { + bool cleanup = true; + // Validate settings and input + if (MaxLogFileDays <= 0) + { + LogLog.Warn(typeof(RollingFileCleanupAppender), "Parameter 'MaxLogFileDays' needs to be a positive integer, aborting cleanup"); + cleanup = false; + } + + if (string.IsNullOrWhiteSpace(BaseFilePattern)) + { + LogLog.Warn(typeof(RollingFileCleanupAppender), "Parameter 'BaseFilePattern' is empty, aborting cleanup"); + cleanup = false; + } + // grab the directory we are logging to, as this is were we will search for older logfiles + var logFolder = Path.GetDirectoryName(fileName); + if (Directory.Exists(logFolder) == false) + { + LogLog.Warn(typeof(RollingFileCleanupAppender), string.Format("Directory '{0}' for logfiles does not exist, aborting cleanup", logFolder)); + cleanup = false; + } + // If everything is validated, we can do the actual cleanup + if (cleanup) + { + Cleanup(logFolder); + } + + base.OpenFile(fileName, append); + } + + private void Cleanup(string directoryPath) + { + // only take files that matches the pattern we are using i.e. UmbracoTraceLog.*.txt.* + string[] logFiles = Directory.GetFiles(directoryPath, BaseFilePattern); + LogLog.Debug(typeof(RollingFileCleanupAppender), string.Format("Found {0} files that matches the baseFilePattern: '{1}'", logFiles.Length, BaseFilePattern)); + + foreach (var logFile in logFiles) + { + DateTime lastAccessTime = System.IO.File.GetLastWriteTimeUtc(logFile); + // take the value from the config file + if (lastAccessTime < DateTime.Now.AddDays(-MaxLogFileDays)) + { + LogLog.Debug(typeof(RollingFileCleanupAppender), string.Format("Deleting file {0} as its lastAccessTime is older than {1} days speficied by MaxLogFileDays", logFile, MaxLogFileDays)); + base.DeleteFile(logFile); + } + } + } + } +} diff --git a/src/Umbraco.Core/Models/Entities/BeingDirtyBase.cs b/src/Umbraco.Core/Models/Entities/BeingDirtyBase.cs index 4e2279caaf..711b7c9b9f 100644 --- a/src/Umbraco.Core/Models/Entities/BeingDirtyBase.cs +++ b/src/Umbraco.Core/Models/Entities/BeingDirtyBase.cs @@ -87,6 +87,15 @@ namespace Umbraco.Core.Models.Entities _currentChanges = null; } + /// + public virtual IEnumerable GetWereDirtyProperties() + { + // ReSharper disable once MergeConditionalExpression + return _savedChanges == null + ? Enumerable.Empty() + : _savedChanges.Where(x => x.Value).Select(x => x.Key); + } + #endregion #region Change Tracking diff --git a/src/Umbraco.Core/Models/Entities/IRememberBeingDirty.cs b/src/Umbraco.Core/Models/Entities/IRememberBeingDirty.cs index 75faba729b..e679b98b93 100644 --- a/src/Umbraco.Core/Models/Entities/IRememberBeingDirty.cs +++ b/src/Umbraco.Core/Models/Entities/IRememberBeingDirty.cs @@ -1,4 +1,6 @@ -namespace Umbraco.Core.Models.Entities +using System.Collections.Generic; + +namespace Umbraco.Core.Models.Entities { /// /// Defines an entity that tracks property changes and can be dirty, and remembers @@ -29,5 +31,10 @@ /// A value indicating whether to remember dirty properties. /// When is true, dirty properties are saved so they can be checked with WasDirty. void ResetDirtyProperties(bool rememberDirty); + + /// + /// Gets properties that were dirty. + /// + IEnumerable GetWereDirtyProperties(); } } diff --git a/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs index 3de0d11de1..eab9e21013 100644 --- a/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs @@ -60,6 +60,14 @@ namespace Umbraco.Core.Models.Identity private BackOfficeIdentityUser() { + _startMediaIds = new int[] { }; + _startContentIds = new int[] { }; + _groups = new IReadOnlyUserGroup[] { }; + _allowedSections = new string[] { }; + _culture = Configuration.GlobalSettings.DefaultUILanguage; + _groups = new IReadOnlyUserGroup[0]; + _roles = new ObservableCollection>(); + _roles.CollectionChanged += _roles_CollectionChanged; } /// diff --git a/src/Umbraco.Core/Models/Identity/IdentityProfile.cs b/src/Umbraco.Core/Models/Identity/IdentityProfile.cs index 46baad29b7..eef2a17aa5 100644 --- a/src/Umbraco.Core/Models/Identity/IdentityProfile.cs +++ b/src/Umbraco.Core/Models/Identity/IdentityProfile.cs @@ -46,20 +46,6 @@ namespace Umbraco.Core.Models.Identity dest.ResetDirtyProperties(true); dest.EnableChangeTracking(); }); - - CreateMap() - .ConstructUsing(source => new UserData(Guid.NewGuid().ToString("N"))) //this is the 'session id' - .ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id)) - .ForMember(dest => dest.AllowedApplications, opt => opt.MapFrom(src => src.AllowedSections)) - .ForMember(dest => dest.Roles, opt => opt.MapFrom(src => new[] { src.Roles.Select(x => x.RoleId).ToArray()})) - .ForMember(dest => dest.RealName, opt => opt.MapFrom(src => src.Name)) - //When mapping to UserData which is used in the authcookie we want ALL start nodes including ones defined on the groups - .ForMember(dest => dest.StartContentNodes, opt => opt.MapFrom(src => src.CalculatedContentStartNodeIds)) - //When mapping to UserData which is used in the authcookie we want ALL start nodes including ones defined on the groups - .ForMember(dest => dest.StartMediaNodes, opt => opt.MapFrom(src => src.CalculatedMediaStartNodeIds)) - .ForMember(dest => dest.Username, opt => opt.MapFrom(src => src.UserName)) - .ForMember(dest => dest.Culture, opt => opt.MapFrom(src => src.Culture)) - .ForMember(dest => dest.SessionId, opt => opt.MapFrom(src => src.SecurityStamp.IsNullOrWhiteSpace() ? Guid.NewGuid().ToString("N") : src.SecurityStamp)); } private static string GetPasswordHash(string storedPass) diff --git a/src/Umbraco.Core/Models/Membership/EntityPermissionCollection.cs b/src/Umbraco.Core/Models/Membership/EntityPermissionCollection.cs index a2f70ef5ef..12e874d5d7 100644 --- a/src/Umbraco.Core/Models/Membership/EntityPermissionCollection.cs +++ b/src/Umbraco.Core/Models/Membership/EntityPermissionCollection.cs @@ -17,7 +17,30 @@ namespace Umbraco.Core.Models.Membership } /// - /// Returns the aggregate permissions in the permission set + /// Returns the aggregate permissions in the permission set for a single node + /// + /// + /// + /// This value is only calculated once per node + /// + public IEnumerable GetAllPermissions(int entityId) + { + if (_aggregateNodePermissions == null) + _aggregateNodePermissions = new Dictionary(); + + string[] entityPermissions; + if (_aggregateNodePermissions.TryGetValue(entityId, out entityPermissions) == false) + { + entityPermissions = this.Where(x => x.EntityId == entityId).SelectMany(x => x.AssignedPermissions).Distinct().ToArray(); + _aggregateNodePermissions[entityId] = entityPermissions; + } + return entityPermissions; + } + + private Dictionary _aggregateNodePermissions; + + /// + /// Returns the aggregate permissions in the permission set for all nodes /// /// /// diff --git a/src/Umbraco.Core/Models/Membership/IUser.cs b/src/Umbraco.Core/Models/Membership/IUser.cs index dec0095243..8219af17b9 100644 --- a/src/Umbraco.Core/Models/Membership/IUser.cs +++ b/src/Umbraco.Core/Models/Membership/IUser.cs @@ -58,6 +58,11 @@ namespace Umbraco.Core.Models.Membership /// /// Will hold the media file system relative path of the users custom avatar if they uploaded one /// - string Avatar { get; set; } + string Avatar { get; set; } + + /// + /// A Json blob stored for recording tour data for a user + /// + string TourData { get; set; } } -} +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Membership/IUserGroup.cs b/src/Umbraco.Core/Models/Membership/IUserGroup.cs index de5842df61..508eb015ed 100644 --- a/src/Umbraco.Core/Models/Membership/IUserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/IUserGroup.cs @@ -3,7 +3,7 @@ using Umbraco.Core.Models.Entities; namespace Umbraco.Core.Models.Membership { - public interface IUserGroup : IEntity + public interface IUserGroup : IEntity, IRememberBeingDirty { string Alias { get; set; } diff --git a/src/Umbraco.Core/Models/Membership/MemberExportModel.cs b/src/Umbraco.Core/Models/Membership/MemberExportModel.cs new file mode 100644 index 0000000000..7153d380b4 --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/MemberExportModel.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace Umbraco.Core.Models.Membership +{ + internal class MemberExportModel + { + public int Id { get; set; } + public Guid Key { get; set; } + public string Name { get; set; } + public string Username { get; set; } + public string Email { get; set; } + public List Groups { get; set; } + public string ContentTypeAlias { get; set; } + public DateTime CreateDate { get; set; } + public DateTime UpdateDate { get; set; } + public List Properties { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/Membership/MemberExportProperty.cs b/src/Umbraco.Core/Models/Membership/MemberExportProperty.cs new file mode 100644 index 0000000000..546d9255ea --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/MemberExportProperty.cs @@ -0,0 +1,14 @@ +using System; + +namespace Umbraco.Core.Models.Membership +{ + internal class MemberExportProperty + { + public int Id { get; set; } + public string Alias { get; set; } + public string Name { get; set; } + public object Value { get; set; } + public DateTime? CreateDate { get; set; } + public DateTime? UpdateDate { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/Membership/User.cs b/src/Umbraco.Core/Models/Membership/User.cs index de410ffb9a..2dd750a353 100644 --- a/src/Umbraco.Core/Models/Membership/User.cs +++ b/src/Umbraco.Core/Models/Membership/User.cs @@ -100,6 +100,7 @@ namespace Umbraco.Core.Models.Membership private string _name; private string _securityStamp; private string _avatar; + private string _tourData; private int _sessionTimeout; private int[] _startContentIds; private int[] _startMediaIds; @@ -133,6 +134,7 @@ namespace Umbraco.Core.Models.Membership public readonly PropertyInfo SecurityStampSelector = ExpressionHelper.GetPropertyInfo(x => x.SecurityStamp); public readonly PropertyInfo AvatarSelector = ExpressionHelper.GetPropertyInfo(x => x.Avatar); + public readonly PropertyInfo TourDataSelector = ExpressionHelper.GetPropertyInfo(x => x.TourData); public readonly PropertyInfo SessionTimeoutSelector = ExpressionHelper.GetPropertyInfo(x => x.SessionTimeout); public readonly PropertyInfo StartContentIdSelector = ExpressionHelper.GetPropertyInfo(x => x.StartContentIds); public readonly PropertyInfo StartMediaIdSelector = ExpressionHelper.GetPropertyInfo(x => x.StartMediaIds); @@ -467,6 +469,16 @@ namespace Umbraco.Core.Models.Membership set { SetPropertyValueAndDetectChanges(value, ref _avatar, Ps.Value.AvatarSelector); } } + /// + /// A Json blob stored for recording tour data for a user + /// + [DataMember] + public string TourData + { + get { return _tourData; } + set { SetPropertyValueAndDetectChanges(value, ref _tourData, Ps.Value.TourDataSelector); } + } + /// /// Gets or sets the session timeout. /// @@ -671,5 +683,6 @@ namespace Umbraco.Core.Models.Membership return _user.GetHashCode(); } } + } } diff --git a/src/Umbraco.Core/Models/Membership/UserGroup.cs b/src/Umbraco.Core/Models/Membership/UserGroup.cs index ccd60f5861..db21c78438 100644 --- a/src/Umbraco.Core/Models/Membership/UserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/UserGroup.cs @@ -12,7 +12,7 @@ namespace Umbraco.Core.Models.Membership /// [Serializable] [DataContract(IsReference = true)] - internal class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup + public class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup { private int? _startContentId; private int? _startMediaId; diff --git a/src/Umbraco.Core/ObjectExtensions.cs b/src/Umbraco.Core/ObjectExtensions.cs index 1c928730e2..4f48322a8f 100644 --- a/src/Umbraco.Core/ObjectExtensions.cs +++ b/src/Umbraco.Core/ObjectExtensions.cs @@ -6,10 +6,10 @@ using System.ComponentModel; using System.Linq; using System.Linq.Expressions; using System.Reflection; +using System.Runtime.CompilerServices; using System.Xml; using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using Umbraco.Core.Composing; +using Umbraco.Core.Collections; namespace Umbraco.Core { @@ -18,8 +18,15 @@ namespace Umbraco.Core /// public static class ObjectExtensions { - private static readonly ConcurrentDictionary> ToObjectTypes - = new ConcurrentDictionary>(); + private static readonly ConcurrentDictionary> ToObjectTypes = new ConcurrentDictionary>(); + private static readonly ConcurrentDictionary NullableGenericCache = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary InputTypeConverterCache = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary DestinationTypeConverterCache = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary AssignableTypeCache = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary BoolConvertCache = new ConcurrentDictionary(); + + private static readonly char[] NumberDecimalSeparatorsToNormalize = { '.', ',' }; + private static readonly CustomBooleanTypeConverter CustomBooleanTypeConverter = new CustomBooleanTypeConverter(); //private static readonly ConcurrentDictionary> ObjectFactoryCache = new ConcurrentDictionary>(); @@ -40,8 +47,8 @@ namespace Umbraco.Core /// public static void DisposeIfDisposable(this object input) { - var disposable = input as IDisposable; - if (disposable != null) disposable.Dispose(); + if (input is IDisposable disposable) + disposable.Dispose(); } /// @@ -53,347 +60,335 @@ namespace Umbraco.Core /// internal static T SafeCast(this object input) { - if (ReferenceEquals(null, input) || ReferenceEquals(default(T), input)) return default(T); - if (input is T) return (T)input; - return default(T); + if (ReferenceEquals(null, input) || ReferenceEquals(default(T), input)) return default; + if (input is T variable) return variable; + return default; } /// - /// Tries to convert the input object to the output type using TypeConverters + /// Attempts to convert the input object to the output type. /// - /// - /// - /// + /// This code is an optimized version of the original Umbraco method + /// The type to convert to + /// The input. + /// The public static Attempt TryConvertTo(this object input) { var result = TryConvertTo(input, typeof(T)); - if (result.Success == false) + + if (result.Success) + return Attempt.Succeed((T)result.Result); + + // just try to cast + try { - //just try a straight up conversion - try - { - var converted = (T) input; - return Attempt.Succeed(converted); - } - catch (Exception e) - { - return Attempt.Fail(e); - } + return Attempt.Succeed((T)input); + } + catch (Exception e) + { + return Attempt.Fail(e); } - return result.Success == false ? Attempt.Fail() : Attempt.Succeed((T)result.Result); } /// - /// Tries to convert the input object to the output type using TypeConverters. If the destination - /// type is a superclass of the input type, if will use . + /// Attempts to convert the input object to the output type. /// + /// This code is an optimized version of the original Umbraco method /// The input. - /// Type of the destination. - /// - public static Attempt TryConvertTo(this object input, Type destinationType) + /// The type to convert to + /// The + public static Attempt TryConvertTo(this object input, Type target) { - // if null... - if (input == null) + if (target == null) { - // nullable is ok - if (destinationType.IsGenericType && destinationType.GetGenericTypeDefinition() == typeof(Nullable<>)) - return Attempt.Succeed(null); - - // value type is nok, else can be null, so is ok - return Attempt.If(destinationType.IsValueType == false, null); + return Attempt.Fail(); } - // easy - if (destinationType == typeof(object)) return Attempt.Succeed(input); - if (input.GetType() == destinationType) return Attempt.Succeed(input); - - // check for string so that overloaders of ToString() can take advantage of the conversion. - if (destinationType == typeof(string)) return Attempt.Succeed(input.ToString()); - - // if we've got a nullable of something, we try to convert directly to that thing. - if (destinationType.IsGenericType && destinationType.GetGenericTypeDefinition() == typeof(Nullable<>)) + try { - var underlyingType = Nullable.GetUnderlyingType(destinationType); - - //special case for empty strings for bools/dates which should return null if an empty string - var asString = input as string; - if (asString != null && string.IsNullOrEmpty(asString) && (underlyingType == typeof(DateTime) || underlyingType == typeof(bool))) + if (input == null) { - return Attempt.Succeed(null); + // Nullable is ok + if (target.IsGenericType && GetCachedGenericNullableType(target) != null) + { + return Attempt.Succeed(null); + } + + // Reference types are ok + return Attempt.If(target.IsValueType == false, null); } - // recursively call into myself with the inner (not-nullable) type and handle the outcome - var nonNullable = input.TryConvertTo(underlyingType); + var inputType = input.GetType(); - // and if sucessful, fall on through to rewrap in a nullable; if failed, pass on the exception - if (nonNullable.Success) - input = nonNullable.Result; // now fall on through... + // Easy + if (target == typeof(object) || inputType == target) + { + return Attempt.Succeed(input); + } + + // Check for string so that overloaders of ToString() can take advantage of the conversion. + if (target == typeof(string)) + { + return Attempt.Succeed(input.ToString()); + } + + // If we've got a nullable of something, we try to convert directly to that thing. + // We cache the destination type and underlying nullable types + // Any other generic types need to fall through + if (target.IsGenericType) + { + var underlying = GetCachedGenericNullableType(target); + if (underlying != null) + { + // Special case for empty strings for bools/dates which should return null if an empty string. + if (input is string inputString) + { + //TODO: Why the check against only bool/date when a string is null/empty? In what scenario can we convert to another type when the string is null or empty other than just being null? + if (string.IsNullOrEmpty(inputString) && (underlying == typeof(DateTime) || underlying == typeof(bool))) + { + return Attempt.Succeed(null); + } + } + + // Recursively call into this method with the inner (not-nullable) type and handle the outcome + var inner = input.TryConvertTo(underlying); + + // And if sucessful, fall on through to rewrap in a nullable; if failed, pass on the exception + if (inner.Success) + { + input = inner.Result; // Now fall on through... + } + else + { + return Attempt.Fail(inner.Exception); + } + } + } else - return Attempt.Fail(nonNullable.Exception); - } - - // we've already dealed with nullables, so any other generic types need to fall through - if (destinationType.IsGenericType == false) - { - if (input is string) { - // try convert from string, returns an Attempt if the string could be - // processed (either succeeded or failed), else null if we need to try - // other methods - var result = TryConvertToFromString(input as string, destinationType); - if (result.HasValue) return result.Value; - } + // target is not a generic type - //TODO: Do a check for destination type being IEnumerable and source type implementing IEnumerable with - // the same 'T', then we'd have to find the extension method for the type AsEnumerable() and execute it. - - if (TypeHelper.IsTypeAssignableFrom(destinationType, input.GetType()) - && TypeHelper.IsTypeAssignableFrom(input)) - { - try + if (input is string inputString) { - var casted = Convert.ChangeType(input, destinationType); - return Attempt.Succeed(casted); + // Try convert from string, returns an Attempt if the string could be + // processed (either succeeded or failed), else null if we need to try + // other methods + var result = TryConvertToFromString(inputString, target); + if (result.HasValue) + { + return result.Value; + } } - catch (Exception e) + + // TODO: Do a check for destination type being IEnumerable and source type implementing IEnumerable with + // the same 'T', then we'd have to find the extension method for the type AsEnumerable() and execute it. + if (GetCachedCanAssign(input, inputType, target)) { - return Attempt.Fail(e); + return Attempt.Succeed(Convert.ChangeType(input, target)); } } - } - var inputConverter = TypeDescriptor.GetConverter(input); - if (inputConverter.CanConvertTo(destinationType)) - { - try + if (target == typeof(bool)) { - var converted = inputConverter.ConvertTo(input, destinationType); - return Attempt.Succeed(converted); - } - catch (Exception e) - { - return Attempt.Fail(e); - } - } - - if (destinationType == typeof(bool)) - { - var boolConverter = new CustomBooleanTypeConverter(); - if (boolConverter.CanConvertFrom(input.GetType())) - { - try + if (GetCachedCanConvertToBoolean(inputType)) { - var converted = boolConverter.ConvertFrom(input); - return Attempt.Succeed(converted); - } - catch (Exception e) - { - return Attempt.Fail(e); + return Attempt.Succeed(CustomBooleanTypeConverter.ConvertFrom(input)); } } - } - var outputConverter = TypeDescriptor.GetConverter(destinationType); - if (outputConverter.CanConvertFrom(input.GetType())) - { - try + var inputConverter = GetCachedSourceTypeConverter(inputType, target); + if (inputConverter != null) { - var converted = outputConverter.ConvertFrom(input); - return Attempt.Succeed(converted); + return Attempt.Succeed(inputConverter.ConvertTo(input, target)); } - catch (Exception e) + + var outputConverter = GetCachedTargetTypeConverter(inputType, target); + if (outputConverter != null) { - return Attempt.Fail(e); + return Attempt.Succeed(outputConverter.ConvertFrom(input)); + } + + if (target.IsGenericType && GetCachedGenericNullableType(target) != null) + { + // cannot Convert.ChangeType as that does not work with nullable + // input has already been converted to the underlying type - just + // return input, there's an implicit conversion from T to T? anyways + return Attempt.Succeed(input); + } + + // Re-check convertables since we altered the input through recursion + if (input is IConvertible convertible2) + { + return Attempt.Succeed(Convert.ChangeType(convertible2, target)); } } - - if (TypeHelper.IsTypeAssignableFrom(input)) + catch (Exception e) { - try - { - var casted = Convert.ChangeType(input, destinationType); - return Attempt.Succeed(casted); - } - catch (Exception e) - { - return Attempt.Fail(e); - } + return Attempt.Fail(e); } return Attempt.Fail(); } - // returns an attempt if the string has been processed (either succeeded or failed) - // returns null if we need to try other methods - private static Attempt? TryConvertToFromString(this string input, Type destinationType) + /// + /// Attempts to convert the input string to the output type. + /// + /// This code is an optimized version of the original Umbraco method + /// The input. + /// The type to convert to + /// The + private static Attempt? TryConvertToFromString(this string input, Type target) { - // easy - if (destinationType == typeof(string)) - return Attempt.Succeed(input); - - // null, empty, whitespaces - if (string.IsNullOrWhiteSpace(input)) + // Easy + if (target == typeof(string)) { - if (destinationType == typeof(bool)) // null/empty = bool false - return Attempt.Succeed(false); - if (destinationType == typeof(DateTime)) // null/empty = min DateTime value - return Attempt.Succeed(DateTime.MinValue); - - // cannot decide here, - // any of the types below will fail parsing and will return a failed attempt - // but anything else will not be processed and will return null - // so even though the string is null/empty we have to proceed + return Attempt.Succeed(input); } - // look for type conversions in the expected order of frequency of use... - if (destinationType.IsPrimitive) + // Null, empty, whitespaces + if (string.IsNullOrWhiteSpace(input)) { - if (destinationType == typeof(int)) // aka Int32 + if (target == typeof(bool)) { - int value; - if (int.TryParse(input, out value)) return Attempt.Succeed(value); + // null/empty = bool false + return Attempt.Succeed(false); + } - // because decimal 100.01m will happily convert to integer 100, it + if (target == typeof(DateTime)) + { + // null/empty = min DateTime value + return Attempt.Succeed(DateTime.MinValue); + } + + // Cannot decide here, + // Any of the types below will fail parsing and will return a failed attempt + // but anything else will not be processed and will return null + // so even though the string is null/empty we have to proceed. + } + + // Look for type conversions in the expected order of frequency of use. + // + // By using a mixture of ordered if statements and switches we can optimize both for + // fast conditional checking for most frequently used types and the branching + // that does not depend on previous values available to switch statements. + if (target.IsPrimitive) + { + if (target == typeof(int)) + { + if (int.TryParse(input, out var value)) + { + return Attempt.Succeed(value); + } + + // Because decimal 100.01m will happily convert to integer 100, it // makes sense that string "100.01" *also* converts to integer 100. - decimal value2; var input2 = NormalizeNumberDecimalSeparator(input); - return Attempt.If(decimal.TryParse(input2, out value2), Convert.ToInt32(value2)); + return Attempt.If(decimal.TryParse(input2, out var value2), Convert.ToInt32(value2)); } - if (destinationType == typeof(long)) // aka Int64 + if (target == typeof(long)) { - long value; - if (long.TryParse(input, out value)) return Attempt.Succeed(value); + if (long.TryParse(input, out var value)) + { + return Attempt.Succeed(value); + } - // same as int - decimal value2; + // Same as int var input2 = NormalizeNumberDecimalSeparator(input); - return Attempt.If(decimal.TryParse(input2, out value2), Convert.ToInt64(value2)); + return Attempt.If(decimal.TryParse(input2, out var value2), Convert.ToInt64(value2)); } - // fixme - should we do the decimal trick for short, byte, unsigned? + // TODO: Should we do the decimal trick for short, byte, unsigned? - if (destinationType == typeof(bool)) // aka Boolean + if (target == typeof(bool)) { - bool value; - if (bool.TryParse(input, out value)) return Attempt.Succeed(value); - // don't declare failure so the CustomBooleanTypeConverter can try + if (bool.TryParse(input, out var value)) + { + return Attempt.Succeed(value); + } + + // Don't declare failure so the CustomBooleanTypeConverter can try return null; } - if (destinationType == typeof(short)) // aka Int16 + // Calling this method directly is faster than any attempt to cache it. + switch (Type.GetTypeCode(target)) { - short value; - return Attempt.If(short.TryParse(input, out value), value); - } + case TypeCode.Int16: + return Attempt.If(short.TryParse(input, out var value), value); - if (destinationType == typeof(double)) // aka Double - { - double value; - var input2 = NormalizeNumberDecimalSeparator(input); - return Attempt.If(double.TryParse(input2, out value), value); - } + case TypeCode.Double: + var input2 = NormalizeNumberDecimalSeparator(input); + return Attempt.If(double.TryParse(input2, out var valueD), valueD); - if (destinationType == typeof(float)) // aka Single - { - float value; - var input2 = NormalizeNumberDecimalSeparator(input); - return Attempt.If(float.TryParse(input2, out value), value); - } + case TypeCode.Single: + var input3 = NormalizeNumberDecimalSeparator(input); + return Attempt.If(float.TryParse(input3, out var valueF), valueF); - if (destinationType == typeof(char)) // aka Char - { - char value; - return Attempt.If(char.TryParse(input, out value), value); - } + case TypeCode.Char: + return Attempt.If(char.TryParse(input, out var valueC), valueC); - if (destinationType == typeof(byte)) // aka Byte - { - byte value; - return Attempt.If(byte.TryParse(input, out value), value); - } + case TypeCode.Byte: + return Attempt.If(byte.TryParse(input, out var valueB), valueB); - if (destinationType == typeof(sbyte)) // aka SByte - { - sbyte value; - return Attempt.If(sbyte.TryParse(input, out value), value); - } + case TypeCode.SByte: + return Attempt.If(sbyte.TryParse(input, out var valueSb), valueSb); - if (destinationType == typeof(uint)) // aka UInt32 - { - uint value; - return Attempt.If(uint.TryParse(input, out value), value); - } + case TypeCode.UInt32: + return Attempt.If(uint.TryParse(input, out var valueU), valueU); - if (destinationType == typeof(ushort)) // aka UInt16 - { - ushort value; - return Attempt.If(ushort.TryParse(input, out value), value); - } + case TypeCode.UInt16: + return Attempt.If(ushort.TryParse(input, out var valueUs), valueUs); - if (destinationType == typeof(ulong)) // aka UInt64 - { - ulong value; - return Attempt.If(ulong.TryParse(input, out value), value); + case TypeCode.UInt64: + return Attempt.If(ulong.TryParse(input, out var valueUl), valueUl); } } - else if (destinationType == typeof(Guid)) + else if (target == typeof(Guid)) { - Guid value; - return Attempt.If(Guid.TryParse(input, out value), value); + return Attempt.If(Guid.TryParse(input, out var value), value); } - else if (destinationType == typeof(DateTime)) + else if (target == typeof(DateTime)) { - DateTime value; - if (DateTime.TryParse(input, out value)) + if (DateTime.TryParse(input, out var value)) { switch (value.Kind) { case DateTimeKind.Unspecified: case DateTimeKind.Utc: return Attempt.Succeed(value); + case DateTimeKind.Local: return Attempt.Succeed(value.ToUniversalTime()); + default: throw new ArgumentOutOfRangeException(); } } + return Attempt.Fail(); } - else if (destinationType == typeof(DateTimeOffset)) + else if (target == typeof(DateTimeOffset)) { - DateTimeOffset value; - return Attempt.If(DateTimeOffset.TryParse(input, out value), value); + return Attempt.If(DateTimeOffset.TryParse(input, out var value), value); } - else if (destinationType == typeof(TimeSpan)) + else if (target == typeof(TimeSpan)) { - TimeSpan value; - return Attempt.If(TimeSpan.TryParse(input, out value), value); + return Attempt.If(TimeSpan.TryParse(input, out var value), value); } - else if (destinationType == typeof(decimal)) // aka Decimal + else if (target == typeof(decimal)) { - decimal value; var input2 = NormalizeNumberDecimalSeparator(input); - return Attempt.If(decimal.TryParse(input2, out value), value); + return Attempt.If(decimal.TryParse(input2, out var value), value); } - else if (destinationType == typeof(Version)) + else if (input != null && target == typeof(Version)) { - Version value; - return Attempt.If(Version.TryParse(input, out value), value); + return Attempt.If(Version.TryParse(input, out var value), value); } + // E_NOTIMPL IPAddress, BigInteger - - return null; // we can't decide... + return null; // we can't decide... } - - private static readonly char[] NumberDecimalSeparatorsToNormalize = {'.', ','}; - - private static string NormalizeNumberDecimalSeparator(string s) - { - var normalized = System.Threading.Thread.CurrentThread.CurrentCulture.NumberFormat.NumberDecimalSeparator[0]; - return s.ReplaceMany(NumberDecimalSeparatorsToNormalize, normalized); - } - internal static void CheckThrowObjectDisposed(this IDisposable disposable, bool isDisposed, string objectname) { //TODO: Localise this exception @@ -475,8 +470,7 @@ namespace Umbraco.Core /// /// /// - public static IDictionary ToDictionary(this T o, - params Expression>[] ignoreProperties) + public static IDictionary ToDictionary(this T o, params Expression>[] ignoreProperties) { return o.ToDictionary(ignoreProperties.Select(e => o.GetPropertyInfo(e)).Select(propInfo => propInfo.Name).ToArray()); } @@ -563,7 +557,7 @@ namespace Umbraco.Core var items = (from object enumItem in enumerable let value = GetEnumPropertyDebugString(enumItem, levels) where value != null select value).Take(10).ToList(); return items.Any() - ? "{{ {0} }}".InvariantFormat(String.Join(", ", items)) + ? "{{ {0} }}".InvariantFormat(string.Join(", ", items)) : null; } @@ -585,9 +579,9 @@ namespace Umbraco.Core { var items = (from propertyInfo in props - let value = GetPropertyDebugString(propertyInfo, obj, levels) - where value != null - select "{0}={1}".InvariantFormat(propertyInfo.Name, value)).ToArray(); + let value = GetPropertyDebugString(propertyInfo, obj, levels) + where value != null + select "{0}={1}".InvariantFormat(propertyInfo.Name, value)).ToArray(); return items.Any() ? "[{0}]:{{ {1} }}".InvariantFormat(obj.GetType().Name, String.Join(", ", items)) @@ -690,7 +684,109 @@ namespace Umbraco.Core internal static Guid AsGuid(this object value) { - return value is Guid ? (Guid) value : Guid.Empty; + return value is Guid guid ? guid : Guid.Empty; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string NormalizeNumberDecimalSeparator(string s) + { + var normalized = System.Threading.Thread.CurrentThread.CurrentCulture.NumberFormat.NumberDecimalSeparator[0]; + return s.ReplaceMany(NumberDecimalSeparatorsToNormalize, normalized); + } + + // gets a converter for source, that can convert to target, or null if none exists + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static TypeConverter GetCachedSourceTypeConverter(Type source, Type target) + { + var key = new CompositeTypeTypeKey(source, target); + + if (InputTypeConverterCache.TryGetValue(key, out var typeConverter)) + { + return typeConverter; + } + + var converter = TypeDescriptor.GetConverter(source); + if (converter.CanConvertTo(target)) + { + return InputTypeConverterCache[key] = converter; + } + + return InputTypeConverterCache[key] = null; + } + + // gets a converter for target, that can convert from source, or null if none exists + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static TypeConverter GetCachedTargetTypeConverter(Type source, Type target) + { + var key = new CompositeTypeTypeKey(source, target); + + if (DestinationTypeConverterCache.TryGetValue(key, out var typeConverter)) + { + return typeConverter; + } + + TypeConverter converter = TypeDescriptor.GetConverter(target); + if (converter.CanConvertFrom(source)) + { + return DestinationTypeConverterCache[key] = converter; + } + + return DestinationTypeConverterCache[key] = null; + } + + // gets the underlying type of a nullable type, or null if the type is not nullable + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Type GetCachedGenericNullableType(Type type) + { + if (NullableGenericCache.TryGetValue(type, out var underlyingType)) + { + return underlyingType; + } + + if (type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + Type underlying = Nullable.GetUnderlyingType(type); + return NullableGenericCache[type] = underlying; + } + + return NullableGenericCache[type] = null; + } + + // gets an IConvertible from source to target type, or null if none exists + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool GetCachedCanAssign(object input, Type source, Type target) + { + var key = new CompositeTypeTypeKey(source, target); + if (AssignableTypeCache.TryGetValue(key, out var canConvert)) + { + return canConvert; + } + + // "object is" is faster than "Type.IsAssignableFrom. + // We can use it to very quickly determine whether true/false + if (input is IConvertible && target.IsAssignableFrom(source)) + { + return AssignableTypeCache[key] = true; + } + + return AssignableTypeCache[key] = false; + } + + // determines whether a type can be converted to boolean + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool GetCachedCanConvertToBoolean(Type type) + { + if (BoolConvertCache.TryGetValue(type, out var result)) + { + return result; + } + + if (CustomBooleanTypeConverter.CanConvertFrom(type)) + { + return BoolConvertCache[type] = true; + } + + return BoolConvertCache[type] = false; } } } diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 43438bfcf1..68f9435219 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -71,6 +71,10 @@ namespace Umbraco.Core public const string TaskType = /*TableNamePrefix*/ "cms" + "TaskType"; public const string KeyValue = TableNamePrefix + "KeyValue"; + + public const string AuditEntry = /*TableNamePrefix*/ "umbraco" + "Audit"; + public const string Consent = /*TableNamePrefix*/ "umbraco" + "Consent"; + public const string UserLogin = /*TableNamePrefix*/ "umbraco" + "UserLogin"; } } } diff --git a/src/Umbraco.Core/Persistence/Dtos/AuditEntryDto.cs b/src/Umbraco.Core/Persistence/Dtos/AuditEntryDto.cs new file mode 100644 index 0000000000..27eeef8e56 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Dtos/AuditEntryDto.cs @@ -0,0 +1,59 @@ +using System; +using NPoco; +using Umbraco.Core.Persistence.DatabaseAnnotations; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; + +namespace Umbraco.Core.Persistence.Dtos +{ + [TableName(Constants.DatabaseSchema.Tables.AuditEntry)] + [PrimaryKey("id")] + [ExplicitColumns] + internal class AuditEntryDto + { + public const int IpLength = 64; + public const int EventTypeLength = 256; + public const int DetailsLength = 1024; + + [Column("id")] + [PrimaryKeyColumn] + public int Id { get; set; } + + // there is NO foreign key to the users table here, neither for performing user nor for + // affected user, so we can delete users and NOT delete the associated audit trails, and + // users can still be identified via the details free-form text fields. + + [Column("performingUserId")] + public int PerformingUserId { get; set; } + + [Column("performingDetails")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(DetailsLength)] + public string PerformingDetails { get; set; } + + [Column("performingIp")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(IpLength)] + public string PerformingIp { get; set; } + + [Column("eventDateUtc")] + [Constraint(Default = SystemMethods.CurrentDateTime)] + public DateTime EventDateUtc { get; set; } + + [Column("affectedUserId")] + public int AffectedUserId { get; set; } + + [Column("affectedDetails")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(DetailsLength)] + public string AffectedDetails { get; set; } + + [Column("eventType")] + [Length(EventTypeLength)] + public string EventType { get; set; } + + [Column("eventDetails")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(DetailsLength)] + public string EventDetails { get; set; } + } +} diff --git a/src/Umbraco.Core/Persistence/Dtos/CacheInstructionDto.cs b/src/Umbraco.Core/Persistence/Dtos/CacheInstructionDto.cs index aeabbfa61b..1fc5eb90a8 100644 --- a/src/Umbraco.Core/Persistence/Dtos/CacheInstructionDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/CacheInstructionDto.cs @@ -27,5 +27,10 @@ namespace Umbraco.Core.Persistence.Dtos [NullSetting(NullSetting = NullSettings.NotNull)] [Length(500)] public string OriginIdentity { get; set; } + + [Column("instructionCount")] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Constraint(Default = 1)] + public int InstructionCount { get; set; } } } diff --git a/src/Umbraco.Core/Persistence/Dtos/ConsentDto.cs b/src/Umbraco.Core/Persistence/Dtos/ConsentDto.cs new file mode 100644 index 0000000000..763df352de --- /dev/null +++ b/src/Umbraco.Core/Persistence/Dtos/ConsentDto.cs @@ -0,0 +1,42 @@ +using System; +using NPoco; +using Umbraco.Core.Persistence.DatabaseAnnotations; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; + +namespace Umbraco.Core.Persistence.Dtos +{ + [TableName(Constants.DatabaseSchema.Tables.Consent)] + [PrimaryKey("id")] + [ExplicitColumns] + public class ConsentDto + { + [Column("id")] + [PrimaryKeyColumn] + public int Id { get; set; } + + [Column("current")] + public bool Current { get; set; } + + [Column("source")] + [Length(512)] + public string Source { get; set; } + + [Column("context")] + [Length(128)] + public string Context { get; set; } + + [Column("action")] + [Length(512)] + public string Action { get; set; } + + [Column("createDate")] + [Constraint(Default = SystemMethods.CurrentDateTime)] + public DateTime CreateDate { get; set; } + + [Column("state")] + public int State { get; set; } + + [Column("comment")] + public string Comment { get; set; } + } +} diff --git a/src/Umbraco.Core/Persistence/Dtos/MemberTypeDto.cs b/src/Umbraco.Core/Persistence/Dtos/MemberTypeDto.cs index 5ff68ab834..545f92bb82 100644 --- a/src/Umbraco.Core/Persistence/Dtos/MemberTypeDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/MemberTypeDto.cs @@ -27,5 +27,9 @@ namespace Umbraco.Core.Persistence.Dtos [Column("viewOnProfile")] [Constraint(Default = "0")] public bool ViewOnProfile { get; set; } + + [Column("isSensitive")] + [Constraint(Default = "0")] + public bool IsSensitive { get; set; } } } diff --git a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs index e305d6480e..8c52aa1e15 100644 --- a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs @@ -27,6 +27,7 @@ namespace Umbraco.Core.Persistence.Dtos [ForeignKey(typeof(PropertyTypeGroupDto))] public int? PropertyTypeGroupId { get; set; } + [Index(IndexTypes.NonClustered, Name = "IX_cmsPropertyTypeAlias")] [Column("Alias")] public string Alias { get; set; } diff --git a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs index 88c02862e2..c68dee42b5 100644 --- a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs @@ -45,6 +45,9 @@ namespace Umbraco.Core.Persistence.Dtos [Column("viewOnProfile")] public bool ViewOnProfile { get; set; } + [Column("isSensitive")] + public bool IsSensitive { get; set; } + /* DataType */ [Column("propertyEditorAlias")] public string PropertyEditorAlias { get; set; } diff --git a/src/Umbraco.Core/Persistence/Dtos/UserDto.cs b/src/Umbraco.Core/Persistence/Dtos/UserDto.cs index b378747a4e..c6cc889ab0 100644 --- a/src/Umbraco.Core/Persistence/Dtos/UserDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/UserDto.cs @@ -104,6 +104,14 @@ namespace Umbraco.Core.Persistence.Dtos [Length(500)] public string Avatar { get; set; } + /// + /// A Json blob stored for recording tour data for a user + /// + [Column("tourData")] + [NullSetting(NullSetting = NullSettings.Null)] + [SpecialDbType(SpecialDbTypes.NTEXT)] + public string TourData { get; set; } + [ResultColumn] [Reference(ReferenceType.Many, ReferenceMemberName = "UserId")] public List UserGroupDtos { get; set; } diff --git a/src/Umbraco.Core/Persistence/Dtos/UserGroupDto.cs b/src/Umbraco.Core/Persistence/Dtos/UserGroupDto.cs index 0479f36878..3383ed9e3d 100644 --- a/src/Umbraco.Core/Persistence/Dtos/UserGroupDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/UserGroupDto.cs @@ -17,7 +17,7 @@ namespace Umbraco.Core.Persistence.Dtos } [Column("id")] - [PrimaryKeyColumn(IdentitySeed = 5)] + [PrimaryKeyColumn(IdentitySeed = 6)] public int Id { get; set; } [Column("userGroupAlias")] diff --git a/src/Umbraco.Core/Persistence/Dtos/UserLoginDto.cs b/src/Umbraco.Core/Persistence/Dtos/UserLoginDto.cs new file mode 100644 index 0000000000..86d306b06a --- /dev/null +++ b/src/Umbraco.Core/Persistence/Dtos/UserLoginDto.cs @@ -0,0 +1,52 @@ +using System; +using NPoco; +using Umbraco.Core.Persistence.DatabaseAnnotations; + +namespace Umbraco.Core.Persistence.Dtos +{ + [TableName(Constants.DatabaseSchema.Tables.UserLogin)] + [PrimaryKey("sessionId", AutoIncrement = false)] + [ExplicitColumns] + internal class UserLoginDto + { + [Column("sessionId")] + [PrimaryKeyColumn(AutoIncrement = false)] + public Guid SessionId { get; set; } + + [Column("userId")] + [ForeignKey(typeof(UserDto), Name = "FK_" + Constants.DatabaseSchema.Tables.UserLogin + "_umbracoUser_id")] + public int UserId { get; set; } + + /// + /// Tracks when the session is created + /// + [Column("loggedInUtc")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public DateTime LoggedInUtc { get; set; } + + /// + /// Updated every time a user's session is validated + /// + /// + /// This allows us to guess if a session is timed out if a user doesn't actively log out + /// and also allows us to trim the data in the table + /// + [Column("lastValidatedUtc")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public DateTime LastValidatedUtc { get; set; } + + /// + /// Tracks when the session is removed when the user's account is logged out + /// + [Column("loggedOutUtc")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? LoggedOutUtc { get; set; } + + /// + /// Logs the IP address of the session if available + /// + [Column("ipAddress")] + [NullSetting(NullSetting = NullSettings.Null)] + public string IpAddress { get; set; } + } +} diff --git a/src/Umbraco.Core/Properties/AssemblyInfo.cs b/src/Umbraco.Core/Properties/AssemblyInfo.cs index c4169147f8..5e411e681c 100644 --- a/src/Umbraco.Core/Properties/AssemblyInfo.cs +++ b/src/Umbraco.Core/Properties/AssemblyInfo.cs @@ -33,5 +33,8 @@ using System.Runtime.InteropServices; [assembly: InternalsVisibleTo("Umbraco.Forms.Core.Providers")] [assembly: InternalsVisibleTo("Umbraco.Forms.Web")] +// Umbraco Headless +[assembly: InternalsVisibleTo("Umbraco.Headless")] + // v8 [assembly: InternalsVisibleTo("Umbraco.Compat7")] diff --git a/src/Umbraco.Core/RuntimeState.cs b/src/Umbraco.Core/RuntimeState.cs index e4f934a60c..3c3ebdbd84 100644 --- a/src/Umbraco.Core/RuntimeState.cs +++ b/src/Umbraco.Core/RuntimeState.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Web; using Semver; @@ -18,6 +19,7 @@ namespace Umbraco.Core private readonly ILogger _logger; private readonly Lazy _serverRegistrar; private readonly Lazy _mainDom; + private readonly HashSet _applicationUrls = new HashSet(); private RuntimeLevel _level; /// @@ -99,7 +101,18 @@ namespace Umbraco.Core /// internal void EnsureApplicationUrl(HttpRequestBase request = null, IUmbracoSettingsSection settings = null) { - if (ApplicationUrl != null) return; + // see U4-10626 - in some cases we want to reset the application url + // (this is a simplified version of what was in 7.x) + // note: should this be optional? is it expensive? + var url = request == null ? null : ApplicationUrlHelper.GetApplicationUrlFromCurrentRequest(request); + var change = url != null && !_applicationUrls.Contains(url); + if (change) + { + _logger.Info(typeof(ApplicationUrlHelper), $"New url \"{url}\" detected, re-discovering application url."); + _applicationUrls.Add(url); + } + + if (ApplicationUrl != null && !change) return; ApplicationUrl = new Uri(ApplicationUrlHelper.GetApplicationUrl(_logger, request, settings)); } diff --git a/src/Umbraco.Core/Security/AuthenticationExtensions.cs b/src/Umbraco.Core/Security/AuthenticationExtensions.cs index 294b8bbd73..eff8b1f958 100644 --- a/src/Umbraco.Core/Security/AuthenticationExtensions.cs +++ b/src/Umbraco.Core/Security/AuthenticationExtensions.cs @@ -11,6 +11,7 @@ using System.Security.Principal; using System.Threading; using System.Web; using System.Web.Security; +using Microsoft.AspNet.Identity; using AutoMapper; using Microsoft.Owin; using Newtonsoft.Json; @@ -18,6 +19,7 @@ using Umbraco.Core.Configuration; using Umbraco.Core.Composing; using Umbraco.Core.Models.Membership; using Umbraco.Core.Logging; +using IUser = Umbraco.Core.Models.Membership.IUser; namespace Umbraco.Core.Security { @@ -224,8 +226,11 @@ namespace Umbraco.Core.Security /// public static FormsAuthenticationTicket CreateUmbracoAuthTicket(this HttpContextBase http, UserData userdata) { + //ONLY used by BasePage.doLogin! + if (http == null) throw new ArgumentNullException("http"); - if (userdata == null) throw new ArgumentNullException("userdata"); + if (userdata == null) throw new ArgumentNullException("userdata"); + var userDataString = JsonConvert.SerializeObject(userdata); return CreateAuthTicketAndCookie( http, @@ -237,14 +242,7 @@ namespace Umbraco.Core.Security 1440, UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName, UmbracoConfig.For.UmbracoSettings().Security.AuthCookieDomain); - } - - internal static FormsAuthenticationTicket CreateUmbracoAuthTicket(this HttpContext http, UserData userdata) - { - if (http == null) throw new ArgumentNullException("http"); - if (userdata == null) throw new ArgumentNullException("userdata"); - return new HttpContextWrapper(http).CreateUmbracoAuthTicket(userdata); - } + } /// /// returns the number of seconds the user has until their auth session times out @@ -314,7 +312,23 @@ namespace Umbraco.Core.Security /// /// private static void Logout(this HttpContextBase http, string cookieName) - { + { + //We need to clear the sessionId from the database. This is legacy code to do any logging out and shouldn't really be used at all but in any case + //we need to make sure the session is cleared. Due to the legacy nature of this it means we need to use singletons + if (http.User != null) + { + var claimsIdentity = http.User.Identity as ClaimsIdentity; + if (claimsIdentity != null) + { + var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + Guid guidSession; + if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession)) + { + ApplicationContext.Current.Services.UserService.ClearLoginSession(guidSession); + } + } + } + if (http == null) throw new ArgumentNullException("http"); //clear the preview cookie and external login var cookies = new[] { cookieName, Constants.Web.PreviewCookieName, Constants.Security.BackOfficeExternalCookieName }; diff --git a/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs index 86c9e17447..4df824544c 100644 --- a/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs +++ b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs @@ -2,16 +2,29 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; +using System.Web; using Microsoft.AspNet.Identity; using Umbraco.Core.Models.Identity; +using Umbraco.Core.Services; namespace Umbraco.Core.Security { public class BackOfficeClaimsIdentityFactory : ClaimsIdentityFactory where T: BackOfficeIdentityUser { + private readonly ApplicationContext _appCtx; + + [Obsolete("Use the overload specifying all dependencies instead")] public BackOfficeClaimsIdentityFactory() + :this(ApplicationContext.Current) { + } + + public BackOfficeClaimsIdentityFactory(ApplicationContext appCtx) + { + if (appCtx == null) throw new ArgumentNullException("appCtx"); + _appCtx = appCtx; + SecurityStampClaimType = Constants.Security.SessionIdClaimType; UserNameClaimType = ClaimTypes.Name; } @@ -24,9 +37,9 @@ namespace Umbraco.Core.Security public override async Task CreateAsync(UserManager manager, T user, string authenticationType) { var baseIdentity = await base.CreateAsync(manager, user, authenticationType); - + var umbracoIdentity = new UmbracoBackOfficeIdentity(baseIdentity, - //set a new session id + //NOTE - there is no session id assigned here, this is just creating the identity, a session id will be generated when the cookie is written new UserData { Id = user.Id, @@ -37,15 +50,22 @@ namespace Umbraco.Core.Security Roles = user.Roles.Select(x => x.RoleId).ToArray(), StartContentNodes = user.CalculatedContentStartNodeIds, StartMediaNodes = user.CalculatedMediaStartNodeIds, - SessionId = user.SecurityStamp + SecurityStamp = user.SecurityStamp }); return umbracoIdentity; - } + } } public class BackOfficeClaimsIdentityFactory : BackOfficeClaimsIdentityFactory { + [Obsolete("Use the overload specifying all dependencies instead")] + public BackOfficeClaimsIdentityFactory() + { + } + public BackOfficeClaimsIdentityFactory(ApplicationContext appCtx) : base(appCtx) + { + } } } diff --git a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs index 9b0b483421..58543f106c 100644 --- a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs +++ b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs @@ -1,21 +1,83 @@ using System; +using System.Collections.Concurrent; +using System.ComponentModel; using System.Globalization; using System.Net.Http.Headers; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNet.Identity; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; +using Semver; using Umbraco.Core.Configuration; +using Umbraco.Core.Models.Identity; namespace Umbraco.Core.Security { public class BackOfficeCookieAuthenticationProvider : CookieAuthenticationProvider { + private readonly ApplicationContext _appCtx; + + [Obsolete("Use the ctor specifying all dependencies")] + [EditorBrowsable(EditorBrowsableState.Never)] + public BackOfficeCookieAuthenticationProvider() + : this(ApplicationContext.Current) + { + } + + public BackOfficeCookieAuthenticationProvider(ApplicationContext appCtx) + { + if (appCtx == null) throw new ArgumentNullException("appCtx"); + _appCtx = appCtx; + } + + private static readonly SemVersion MinUmbracoVersionSupportingLoginSessions = new SemVersion(7, 8); + + public override void ResponseSignIn(CookieResponseSignInContext context) + { + var backOfficeIdentity = context.Identity as UmbracoBackOfficeIdentity; + if (backOfficeIdentity != null) + { + //generate a session id and assign it + //create a session token - if we are configured and not in an upgrade state then use the db, otherwise just generate one + + //NOTE - special check because when we are upgrading to 7.8 we cannot create a session since the db isn't ready and we'll get exceptions + var canAcquireSession = _appCtx.IsUpgrading == false || _appCtx.CurrentVersion() >= MinUmbracoVersionSupportingLoginSessions; + + var session = canAcquireSession + ? _appCtx.Services.UserService.CreateLoginSession((int)backOfficeIdentity.Id, context.OwinContext.GetCurrentRequestIpAddress()) + : Guid.NewGuid(); + + backOfficeIdentity.UserData.SessionId = session.ToString(); + } + + base.ResponseSignIn(context); + } + public override void ResponseSignOut(CookieResponseSignOutContext context) { + //Clear the user's session on sign out + if (context != null && context.OwinContext != null && context.OwinContext.Authentication != null + && context.OwinContext.Authentication.User != null && context.OwinContext.Authentication.User.Identity != null) + { + var claimsIdentity = context.OwinContext.Authentication.User.Identity as ClaimsIdentity; + var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + Guid guidSession; + if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession)) + { + _appCtx.Services.UserService.ClearLoginSession(guidSession); + } + } + base.ResponseSignOut(context); //Make sure the definitely all of these cookies are cleared when signing out with cookies + context.Response.Cookies.Append(SessionIdValidator.CookieName, "", new CookieOptions + { + Expires = DateTime.Now.AddYears(-1), + Path = "/" + }); context.Response.Cookies.Append(UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName, "", new CookieOptions { Expires = DateTime.Now.AddYears(-1), @@ -34,21 +96,45 @@ namespace Umbraco.Core.Security } /// - /// Ensures that the culture is set correctly for the current back office user + /// Ensures that the culture is set correctly for the current back office user and that the user's session token is valid /// /// /// - public override Task ValidateIdentity(CookieValidateIdentityContext context) + public override async Task ValidateIdentity(CookieValidateIdentityContext context) + { + EnsureCulture(context); + + await EnsureValidSessionId(context); + + await base.ValidateIdentity(context); + } + + /// + /// Ensures that the user has a valid session id + /// + /// + /// So that we are not overloading the database this throttles it's check to every minute + /// + protected virtual async Task EnsureValidSessionId(CookieValidateIdentityContext context) + { + if (_appCtx.IsConfigured && _appCtx.IsUpgrading == false) + await SessionIdValidator.ValidateSessionAsync(TimeSpan.FromMinutes(1), context); + } + + private void EnsureCulture(CookieValidateIdentityContext context) { var umbIdentity = context.Identity as UmbracoBackOfficeIdentity; if (umbIdentity != null && umbIdentity.IsAuthenticated) { Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = - new CultureInfo(umbIdentity.Culture); + UserCultures.GetOrAdd(umbIdentity.Culture, s => new CultureInfo(s)); } - - return base.ValidateIdentity(context); } + + /// + /// Used so that we aren't creating a new CultureInfo object for every single request + /// + private static readonly ConcurrentDictionary UserCultures = new ConcurrentDictionary(); } } diff --git a/src/Umbraco.Core/Security/BackOfficeSignInManager.cs b/src/Umbraco.Core/Security/BackOfficeSignInManager.cs index a6c0ae6b49..0362ab00b8 100644 --- a/src/Umbraco.Core/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Core/Security/BackOfficeSignInManager.cs @@ -12,6 +12,7 @@ using Umbraco.Core.Models.Identity; namespace Umbraco.Core.Security { + //TODO: In v8 we need to change this to use an int? nullable TKey instead, see notes against overridden TwoFactorSignInAsync public class BackOfficeSignInManager : SignInManager { private readonly ILogger _logger; @@ -222,6 +223,9 @@ namespace Umbraco.Core.Security user.AccessFailedCount = 0; await UserManager.UpdateAsync(user); + //set the current request's principal to the identity just signed in! + _request.User = new ClaimsPrincipal(userIdentity); + _logger.WriteCore(TraceEventType.Information, 0, string.Format( "Login attempt succeeded for username {0} from IP address {1}", @@ -259,5 +263,67 @@ namespace Umbraco.Core.Security } return null; } + + /// + /// Two factor verification step + /// + /// + /// + /// + /// + /// + /// + /// This is implemented because we cannot override GetVerifiedUserIdAsync and instead we have to shadow it + /// so due to this and because we are using an INT as the TKey and not an object, it can never be null. Adding to that + /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate + /// all of this code to check for -1 instead. + /// + public override async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberBrowser) + { + var userId = await GetVerifiedUserIdAsync(); + if (userId == -1) + { + return SignInStatus.Failure; + } + var user = await UserManager.FindByIdAsync(userId); + if (user == null) + { + return SignInStatus.Failure; + } + if (await UserManager.IsLockedOutAsync(user.Id)) + { + return SignInStatus.LockedOut; + } + if (await UserManager.VerifyTwoFactorTokenAsync(user.Id, provider, code)) + { + // When token is verified correctly, clear the access failed count used for lockout + await UserManager.ResetAccessFailedCountAsync(user.Id); + await SignInAsync(user, isPersistent, rememberBrowser); + return SignInStatus.Success; + } + // If the token is incorrect, record the failure which also may cause the user to be locked out + await UserManager.AccessFailedAsync(user.Id); + return SignInStatus.Failure; + } + + /// Send a two factor code to a user + /// + /// + /// + /// This is implemented because we cannot override GetVerifiedUserIdAsync and instead we have to shadow it + /// so due to this and because we are using an INT as the TKey and not an object, it can never be null. Adding to that + /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate + /// all of this code to check for -1 instead. + /// + public override async Task SendTwoFactorCodeAsync(string provider) + { + var userId = await GetVerifiedUserIdAsync(); + if (userId == -1) + return false; + + var token = await UserManager.GenerateTwoFactorTokenAsync(userId, provider); + var identityResult = await UserManager.NotifyTwoFactorTokenAsync(userId, provider, token); + return identityResult.Succeeded; + } } } diff --git a/src/Umbraco.Core/Security/BackOfficeUserManager.cs b/src/Umbraco.Core/Security/BackOfficeUserManager.cs index 865f3f27cc..32f7d3bd8f 100644 --- a/src/Umbraco.Core/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Core/Security/BackOfficeUserManager.cs @@ -157,7 +157,7 @@ namespace Umbraco.Core.Security #region What we support do not currently - //NOTE: Not sure if we really want/need to ever support this + //TODO: We could support this - but a user claims will mostly just be what is in the auth cookie public override bool SupportsUserClaim { get { return false; } @@ -259,6 +259,22 @@ namespace Umbraco.Core.Security //manager.SmsService = new SmsService(); } + /// + /// Used to validate a user's session + /// + /// + /// + /// + public virtual async Task ValidateSessionIdAsync(int userId, string sessionId) + { + var userSessionStore = Store as IUserSessionStore; + //if this is not set, for backwards compat (which would be super rare), we'll just approve it + if (userSessionStore == null) + return true; + + return await userSessionStore.ValidateSessionIdAsync(userId, sessionId); + } + /// /// This will determine which password hasher to use based on what is defined in config /// @@ -393,6 +409,33 @@ namespace Umbraco.Core.Security return await base.CheckPasswordAsync(user, password); } + public override Task ResetPasswordAsync(int userId, string token, string newPassword) + { + var result = base.ResetPasswordAsync(userId, token, newPassword); + if (result.Result.Succeeded) + RaisePasswordResetEvent(userId); + return result; + } + + /// + /// This is a special method that will reset the password but will raise the Password Changed event instead of the reset event + /// + /// + /// + /// + /// + /// + /// We use this because in the back office the only way an admin can change another user's password without first knowing their password + /// is to generate a token and reset it, however, when we do this we want to track a password change, not a password reset + /// + public Task ChangePasswordWithResetAsync(int userId, string token, string newPassword) + { + var result = base.ResetPasswordAsync(userId, token, newPassword); + if (result.Result.Succeeded) + RaisePasswordChangedEvent(userId); + return result; + } + public override Task ChangePasswordAsync(int userId, string currentPassword, string newPassword) { var result = base.ChangePasswordAsync(userId, currentPassword, newPassword); @@ -527,27 +570,27 @@ namespace Umbraco.Core.Security internal void RaiseAccountLockedEvent(int userId) { - OnAccountLocked(new IdentityAuditEventArgs(AuditEvent.AccountLocked, GetCurrentRequestIpAddress(), userId)); + OnAccountLocked(new IdentityAuditEventArgs(AuditEvent.AccountLocked, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseAccountUnlockedEvent(int userId) { - OnAccountUnlocked(new IdentityAuditEventArgs(AuditEvent.AccountUnlocked, GetCurrentRequestIpAddress(), userId)); + OnAccountUnlocked(new IdentityAuditEventArgs(AuditEvent.AccountUnlocked, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseForgotPasswordRequestedEvent(int userId) { - OnForgotPasswordRequested(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordRequested, GetCurrentRequestIpAddress(), userId)); + OnForgotPasswordRequested(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordRequested, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseForgotPasswordChangedSuccessEvent(int userId) { - OnForgotPasswordChangedSuccess(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordChangedSuccess, GetCurrentRequestIpAddress(), userId)); + OnForgotPasswordChangedSuccess(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordChangedSuccess, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseLoginFailedEvent(int userId) { - OnLoginFailed(new IdentityAuditEventArgs(AuditEvent.LoginFailed, GetCurrentRequestIpAddress(), userId)); + OnLoginFailed(new IdentityAuditEventArgs(AuditEvent.LoginFailed, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseInvalidLoginAttemptEvent(string username) @@ -557,31 +600,33 @@ namespace Umbraco.Core.Security internal void RaiseLoginRequiresVerificationEvent(int userId) { - OnLoginRequiresVerification(new IdentityAuditEventArgs(AuditEvent.LoginRequiresVerification, GetCurrentRequestIpAddress(), userId)); + OnLoginRequiresVerification(new IdentityAuditEventArgs(AuditEvent.LoginRequiresVerification, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseLoginSuccessEvent(int userId) { - OnLoginSuccess(new IdentityAuditEventArgs(AuditEvent.LoginSucces, GetCurrentRequestIpAddress(), userId)); + OnLoginSuccess(new IdentityAuditEventArgs(AuditEvent.LoginSucces, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaiseLogoutSuccessEvent(int userId) { - OnLogoutSuccess(new IdentityAuditEventArgs(AuditEvent.LogoutSuccess, GetCurrentRequestIpAddress(), userId)); + OnLogoutSuccess(new IdentityAuditEventArgs(AuditEvent.LogoutSuccess, GetCurrentRequestIpAddress(), affectedUser: userId)); } internal void RaisePasswordChangedEvent(int userId) { - OnPasswordChanged(new IdentityAuditEventArgs(AuditEvent.PasswordChanged, GetCurrentRequestIpAddress(), userId)); + OnPasswordChanged(new IdentityAuditEventArgs(AuditEvent.PasswordChanged, GetCurrentRequestIpAddress(), affectedUser: userId)); } + //TODO: I don't think this is required anymore since from 7.7 we no longer display the reset password checkbox since that didn't make sense. internal void RaisePasswordResetEvent(int userId) { - OnPasswordReset(new IdentityAuditEventArgs(AuditEvent.PasswordReset, GetCurrentRequestIpAddress(), userId)); + OnPasswordReset(new IdentityAuditEventArgs(AuditEvent.PasswordReset, GetCurrentRequestIpAddress(), affectedUser: userId)); } + internal void RaiseResetAccessFailedCountEvent(int userId) { - OnResetAccessFailedCount(new IdentityAuditEventArgs(AuditEvent.ResetAccessFailedCount, GetCurrentRequestIpAddress(), userId)); + OnResetAccessFailedCount(new IdentityAuditEventArgs(AuditEvent.ResetAccessFailedCount, GetCurrentRequestIpAddress(), affectedUser: userId)); } public static event EventHandler AccountLocked; @@ -662,4 +707,5 @@ namespace Umbraco.Core.Security return httpContext.GetCurrentRequestIpAddress(); } } + } diff --git a/src/Umbraco.Core/Security/BackOfficeUserStore.cs b/src/Umbraco.Core/Security/BackOfficeUserStore.cs index e5500e689c..dc6a939c65 100644 --- a/src/Umbraco.Core/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Core/Security/BackOfficeUserStore.cs @@ -18,7 +18,7 @@ using Task = System.Threading.Tasks.Task; namespace Umbraco.Core.Security { - public class BackOfficeUserStore : DisposableObject, + public class BackOfficeUserStore : DisposableObjectSlim, IUserStore, IUserPasswordStore, IUserEmailStore, @@ -26,12 +26,13 @@ namespace Umbraco.Core.Security IUserRoleStore, IUserSecurityStampStore, IUserLockoutStore, - IUserTwoFactorStore + IUserTwoFactorStore, + IUserSessionStore - //TODO: This would require additional columns/tables for now people will need to implement this on their own - //IUserPhoneNumberStore, - //TODO: To do this we need to implement IQueryable - we'll have an IQuerable implementation soon with the UmbracoLinqPadDriver implementation - //IQueryableUserStore + //TODO: This would require additional columns/tables for now people will need to implement this on their own + //IUserPhoneNumberStore, + //TODO: To do this we need to implement IQueryable - we'll have an IQuerable implementation soon with the UmbracoLinqPadDriver implementation + //IQueryableUserStore { private readonly IUserService _userService; private readonly IMemberTypeService _memberTypeService; @@ -59,7 +60,7 @@ namespace Umbraco.Core.Security } /// - /// Handles the disposal of resources. Derived from abstract class which handles common required locking logic. + /// Handles the disposal of resources. Derived from abstract class which handles common required locking logic. /// protected override void DisposeResources() { @@ -126,12 +127,15 @@ namespace Umbraco.Core.Security var found = _userService.GetUserById(asInt.Result); if (found != null) { + // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. + var isLoginsPropertyDirty = user.IsPropertyDirty("Logins"); + if (UpdateMemberProperties(found, user)) { _userService.Save(found); } - if (user.IsPropertyDirty("Logins")) + if (isLoginsPropertyDirty) { var logins = await GetLoginsAsync(user); _externalLoginService.SaveUserLogins(found.Id, logins); @@ -752,5 +756,15 @@ namespace Umbraco.Core.Security if (_disposed) throw new ObjectDisposedException(GetType().Name); } + + public Task ValidateSessionIdAsync(int userId, string sessionId) + { + Guid guidSessionId; + if (Guid.TryParse(sessionId, out guidSessionId)) + { + return Task.FromResult(_userService.ValidateLoginSession(userId, guidSessionId)); + } + return Task.FromResult(false); + } } } diff --git a/src/Umbraco.Core/Security/IUserSessionStore.cs b/src/Umbraco.Core/Security/IUserSessionStore.cs new file mode 100644 index 0000000000..3454b19f84 --- /dev/null +++ b/src/Umbraco.Core/Security/IUserSessionStore.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNet.Identity; + +namespace Umbraco.Core.Security +{ + /// + /// An IUserStore interface part to implement if the store supports validating user session Ids + /// + /// + /// + public interface IUserSessionStore : IUserStore, IDisposable + where TUser : class, IUser + { + Task ValidateSessionIdAsync(int userId, string sessionId); + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Security/MembershipProviderBase.cs b/src/Umbraco.Core/Security/MembershipProviderBase.cs index 79eaa44afd..74170a4335 100644 --- a/src/Umbraco.Core/Security/MembershipProviderBase.cs +++ b/src/Umbraco.Core/Security/MembershipProviderBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Specialized; +using System.ComponentModel.DataAnnotations; using System.Configuration.Provider; using System.Security.Cryptography; using System.Text; @@ -9,6 +10,7 @@ using System.Web.Configuration; using System.Web.Hosting; using System.Web.Security; using Umbraco.Core.Composing; +using Umbraco.Core.Configuration; using Umbraco.Core.Logging; using Umbraco.Core.Models; @@ -677,11 +679,7 @@ namespace Umbraco.Core.Security internal static bool IsEmailValid(string email) { - const string pattern = @"^(?!\.)(""([^""\r\\]|\\[""\r\\])*""|" - + @"([-a-z0-9!#$%&'*+/=?^_`{|}~]|(? + /// Nasty little hack to get httpcontextbase from an owin context + /// + /// + /// + internal static Attempt TryGetHttpContext(this IOwinContext owinContext) + { + var ctx = owinContext.Get(typeof(HttpContextBase).FullName); + return ctx == null ? Attempt.Fail() : Attempt.Succeed(ctx); + } + /// /// Gets the back office sign in manager out of OWIN /// diff --git a/src/Umbraco.Core/Security/SessionIdValidator.cs b/src/Umbraco.Core/Security/SessionIdValidator.cs new file mode 100644 index 0000000000..1737baa778 --- /dev/null +++ b/src/Umbraco.Core/Security/SessionIdValidator.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Microsoft.AspNet.Identity; +using Microsoft.AspNet.Identity.Owin; +using Microsoft.Owin; +using Microsoft.Owin.Infrastructure; +using Microsoft.Owin.Security.Cookies; +using Umbraco.Core.Configuration; +using Umbraco.Core.Models.Identity; + +namespace Umbraco.Core.Security +{ + /// + /// Static helper class used to configure a CookieAuthenticationProvider to validate a cookie against a user's session id + /// + /// + /// This uses another cookie to track the last checked time which is done for a few reasons: + /// * We can't use the user's auth ticket to do thsi because we'd be re-issuing the auth ticket all of the time and it would never expire + /// plus the auth ticket size is much larger than this small value + /// * This will execute quite often (every minute per user) and in some cases there might be several requests that end up re-issuing the cookie so the cookie value should be small + /// * We want to avoid the user lookup if it's not required so that will only happen when the time diff is great enough in the cookie + /// + internal static class SessionIdValidator + { + public const string CookieName = "UMB_UCONTEXT_C"; + + public static async Task ValidateSessionAsync(TimeSpan validateInterval, CookieValidateIdentityContext context) + { + if (context.Request.Uri.IsBackOfficeRequest(HttpRuntime.AppDomainAppVirtualPath) == false) + return; + + var valid = await ValidateSessionAsync(validateInterval, context.OwinContext, context.Options.CookieManager, context.Options.SystemClock, context.Properties.IssuedUtc, context.Identity); + + if (valid == false) + { + context.RejectIdentity(); + context.OwinContext.Authentication.SignOut(context.Options.AuthenticationType); + } + } + + public static async Task ValidateSessionAsync( + TimeSpan validateInterval, + IOwinContext owinCtx, + ICookieManager cookieManager, + ISystemClock systemClock, + DateTimeOffset? authTicketIssueDate, + ClaimsIdentity currentIdentity) + { + if (owinCtx == null) throw new ArgumentNullException("owinCtx"); + if (cookieManager == null) throw new ArgumentNullException("cookieManager"); + if (systemClock == null) throw new ArgumentNullException("systemClock"); + + DateTimeOffset? issuedUtc = null; + var currentUtc = systemClock.UtcNow; + + //read the last checked time from a custom cookie + var lastCheckedCookie = cookieManager.GetRequestCookie(owinCtx, CookieName); + + if (lastCheckedCookie.IsNullOrWhiteSpace() == false) + { + DateTimeOffset parsed; + if (DateTimeOffset.TryParse(lastCheckedCookie, out parsed)) + { + issuedUtc = parsed; + } + } + + //no cookie, use the issue time of the auth ticket + if (issuedUtc.HasValue == false) + { + issuedUtc = authTicketIssueDate; + } + + // Only validate if enough time has elapsed + var validate = issuedUtc.HasValue == false; + if (issuedUtc.HasValue) + { + var timeElapsed = currentUtc.Subtract(issuedUtc.Value); + validate = timeElapsed > validateInterval; + } + + if (validate == false) + return true; + + var manager = owinCtx.GetUserManager(); + if (manager == null) + return false; + + var userId = currentIdentity.GetUserId(); + var user = await manager.FindByIdAsync(userId); + if (user == null) + return false; + + var sessionId = currentIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + if (await manager.ValidateSessionIdAsync(userId, sessionId) == false) + return false; + + //we will re-issue the cookie last checked cookie + cookieManager.AppendResponseCookie( + owinCtx, + CookieName, + DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz"), + new CookieOptions + { + HttpOnly = true, + Secure = GlobalSettings.UseSSL || owinCtx.Request.IsSecure, + Path = "/" + }); + + return true; + } + + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs b/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs index f9f3da77b9..605c8b4e9d 100644 --- a/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs +++ b/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs @@ -36,6 +36,7 @@ namespace Umbraco.Core.Security var username = identity.GetUserName(); var session = identity.FindFirstValue(Constants.Security.SessionIdClaimType); + var securityStamp = identity.FindFirstValue(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType); var startContentId = identity.FindFirstValue(Constants.Security.StartContentNodeIdClaimType); var startMediaId = identity.FindFirstValue(Constants.Security.StartMediaNodeIdClaimType); @@ -66,8 +67,9 @@ namespace Umbraco.Core.Security var roles = identity.FindAll(x => x.Type == DefaultRoleClaimType).Select(role => role.Value).ToList(); var allowedApps = identity.FindAll(x => x.Type == Constants.Security.AllowedApplicationsClaimType).Select(app => app.Value).ToList(); - var userData = new UserData(session) + var userData = new UserData { + SecurityStamp = securityStamp, SessionId = session, AllowedApplications = allowedApps.ToArray(), Culture = culture, @@ -189,7 +191,8 @@ namespace Umbraco.Core.Security Constants.Security.StartContentNodeIdClaimType, Constants.Security.StartMediaNodeIdClaimType, ClaimTypes.Locality, - Constants.Security.SessionIdClaimType + Constants.Security.SessionIdClaimType, + Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType }; } } @@ -219,16 +222,12 @@ namespace Umbraco.Core.Security AddClaim(new Claim(ClaimTypes.Locality, Culture, ClaimValueTypes.String, Issuer, Issuer, this)); if (HasClaim(x => x.Type == Constants.Security.SessionIdClaimType) == false && SessionId.IsNullOrWhiteSpace() == false) - { AddClaim(new Claim(Constants.Security.SessionIdClaimType, SessionId, ClaimValueTypes.String, Issuer, Issuer, this)); - //The security stamp claim is also required... this is because this claim type is hard coded - // by the SecurityStampValidator, see: https://katanaproject.codeplex.com/workitem/444 - if (HasClaim(x => x.Type == Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType) == false) - { - AddClaim(new Claim(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType, SessionId, ClaimValueTypes.String, Issuer, Issuer, this)); - } - } + //The security stamp claim is also required... this is because this claim type is hard coded + // by the SecurityStampValidator, see: https://katanaproject.codeplex.com/workitem/444 + if (HasClaim(x => x.Type == Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType) == false) + AddClaim(new Claim(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType, SecurityStamp, ClaimValueTypes.String, Issuer, Issuer, this)); //Add each app as a separate claim if (HasClaim(x => x.Type == Constants.Security.AllowedApplicationsClaimType) == false) @@ -307,6 +306,11 @@ namespace Umbraco.Core.Security get { return UserData.SessionId; } } + public string SecurityStamp + { + get { return UserData.SecurityStamp; } + } + public string[] Roles { get { return UserData.Roles; } diff --git a/src/Umbraco.Core/Security/UserData.cs b/src/Umbraco.Core/Security/UserData.cs index 9f80343ace..7e510ba708 100644 --- a/src/Umbraco.Core/Security/UserData.cs +++ b/src/Umbraco.Core/Security/UserData.cs @@ -20,7 +20,7 @@ namespace Umbraco.Core.Security /// Use this constructor to create/assign new UserData to the ticket /// /// - /// The security stamp for the user + /// The current sessionId for the user /// public UserData(string sessionId) { @@ -30,11 +30,17 @@ namespace Umbraco.Core.Security } /// - /// This is the 'security stamp' for validation + /// Gets or sets the session identifier. /// [DataMember(Name = "sessionId")] public string SessionId { get; set; } + /// + /// Gets or sets the security stamp. + /// + [DataMember(Name = "securityStamp")] + public string SecurityStamp { get; set; } + [DataMember(Name = "id")] public object Id { get; set; } diff --git a/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs b/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs index 8980c13ec4..1e40f46775 100644 --- a/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs +++ b/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs @@ -100,7 +100,7 @@ namespace Umbraco.Core.Sync return null; } - private static string GetApplicationUrlFromCurrentRequest(HttpRequestBase request) + public static string GetApplicationUrlFromCurrentRequest(HttpRequestBase request) { // if (HTTP and SSL not required) or (HTTPS and SSL required), // use ports from request diff --git a/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs b/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs index d1a3e11b15..cf545d0687 100644 --- a/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs +++ b/src/Umbraco.Core/Sync/DatabaseServerMessenger.cs @@ -15,6 +15,7 @@ using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Configuration; using Umbraco.Core.Scoping; namespace Umbraco.Core.Sync @@ -34,6 +35,7 @@ namespace Umbraco.Core.Sync private readonly object _locko = new object(); private readonly ProfilingLogger _profilingLogger; private readonly ISqlContext _sqlContext; + private readonly Lazy _distCacheFilePath = new Lazy(GetDistCacheFilePath); private int _lastId = -1; private DateTime _lastSync; private DateTime _lastPruned; @@ -63,6 +65,8 @@ namespace Umbraco.Core.Sync protected IScopeProvider ScopeProvider { get; } protected Sql Sql() => _sqlContext.Sql(); + + private string DistCacheFilePath => _distCacheFilePath.Value; #region Messenger @@ -92,7 +96,8 @@ namespace Umbraco.Core.Sync { UtcStamp = DateTime.UtcNow, Instructions = JsonConvert.SerializeObject(instructions, Formatting.None), - OriginIdentity = LocalIdentity + OriginIdentity = LocalIdentity, + InstructionCount = instructions.Sum(x => x.JsonIdCount) }; using (var scope = ScopeProvider.CreateScope()) @@ -182,10 +187,9 @@ namespace Umbraco.Core.Sync } else { - //check for how many instructions there are to process - //TODO: In 7.6 we need to store the count of instructions per row since this is not affective because there can be far more than one (if not thousands) - // of instructions in a single row. - var count = database.ExecuteScalar("SELECT COUNT(*) FROM umbracoCacheInstruction WHERE id > @lastId", new {lastId = _lastId}); + //check for how many instructions there are to process, each row contains a count of the number of instructions contained in each + //row so we will sum these numbers to get the actual count. + var count = database.ExecuteScalar("SELECT SUM(instructionCount) FROM umbracoCacheInstruction WHERE id > @lastId", new {lastId = _lastId}); if (count > Options.MaxProcessingInstructionCount) { //too many instructions, proceed to cold boot @@ -222,7 +226,7 @@ namespace Umbraco.Core.Sync /// /// Synchronize the server (throttled). /// - protected void Sync() + protected internal void Sync() { lock (_locko) { @@ -484,11 +488,10 @@ namespace Umbraco.Core.Sync /// private void ReadLastSynced() { - var path = SyncFilePath; - if (File.Exists(path) == false) return; + if (File.Exists(DistCacheFilePath) == false) return; - var content = File.ReadAllText(path); - if (int.TryParse(content, out int last)) + var content = File.ReadAllText(DistCacheFilePath); + if (int.TryParse(content, out var last)) _lastId = last; } @@ -501,7 +504,7 @@ namespace Umbraco.Core.Sync /// private void SaveLastSynced(int id) { - File.WriteAllText(SyncFilePath, id.ToString(CultureInfo.InvariantCulture)); + File.WriteAllText(DistCacheFilePath, id.ToString(CultureInfo.InvariantCulture)); _lastId = id; } @@ -521,20 +524,40 @@ namespace Umbraco.Core.Sync + "/D" + AppDomain.CurrentDomain.Id // eg 22 + "] " + Guid.NewGuid().ToString("N").ToUpper(); // make it truly unique - /// - /// Gets the sync file path for the local server. - /// - /// The sync file path for the local server. - private static string SyncFilePath + private static string GetDistCacheFilePath() { - get - { - var tempFolder = IOHelper.MapPath("~/App_Data/TEMP/DistCache/" + NetworkHelper.FileSafeMachineName); - if (Directory.Exists(tempFolder) == false) - Directory.CreateDirectory(tempFolder); + var fileName = HttpRuntime.AppDomainAppId.ReplaceNonAlphanumericChars(string.Empty) + "-lastsynced.txt"; - return Path.Combine(tempFolder, HttpRuntime.AppDomainAppId.ReplaceNonAlphanumericChars(string.Empty) + "-lastsynced.txt"); + string distCacheFilePath; + switch (GlobalSettings.LocalTempStorageLocation) + { + case LocalTempStorage.AspNetTemp: + distCacheFilePath = Path.Combine(HttpRuntime.CodegenDir, @"UmbracoData", fileName); + break; + case LocalTempStorage.EnvironmentTemp: + var appDomainHash = HttpRuntime.AppDomainAppId.ToSHA1(); + var cachePath = Path.Combine(Environment.ExpandEnvironmentVariables("%temp%"), "UmbracoData", + //include the appdomain hash is just a safety check, for example if a website is moved from worker A to worker B and then back + // to worker A again, in theory the %temp% folder should already be empty but we really want to make sure that its not + // utilizing an old path + appDomainHash); + distCacheFilePath = Path.Combine(cachePath, fileName); + break; + case LocalTempStorage.Default: + default: + var tempFolder = IOHelper.MapPath("~/App_Data/TEMP/DistCache"); + distCacheFilePath = Path.Combine(tempFolder, fileName); + break; } + + //ensure the folder exists + var folder = Path.GetDirectoryName(distCacheFilePath); + if (folder == null) + throw new InvalidOperationException("The folder could not be determined for the file " + distCacheFilePath); + if (Directory.Exists(folder) == false) + Directory.CreateDirectory(folder); + + return distCacheFilePath; } #endregion diff --git a/src/Umbraco.Core/Sync/RefreshInstruction.cs b/src/Umbraco.Core/Sync/RefreshInstruction.cs index b6caf42db6..3038f95e65 100644 --- a/src/Umbraco.Core/Sync/RefreshInstruction.cs +++ b/src/Umbraco.Core/Sync/RefreshInstruction.cs @@ -17,7 +17,10 @@ namespace Umbraco.Core.Sync // need this public, parameter-less constructor so the web service messenger // can de-serialize the instructions it receives public RefreshInstruction() - { } + { + //set default - this value is not used for reading after it's been deserialized, it's only used for persisting the instruction to the db + JsonIdCount = 1; + } // need this public one so it can be de-serialized - used by the Json thing // otherwise, should use GetInstructions(...) @@ -29,12 +32,16 @@ namespace Umbraco.Core.Sync IntId = intId; JsonIds = jsonIds; JsonPayload = jsonPayload; + //set default - this value is not used for reading after it's been deserialized, it's only used for persisting the instruction to the db + JsonIdCount = 1; } private RefreshInstruction(ICacheRefresher refresher, RefreshMethodType refreshType) { RefresherId = refresher.RefresherUniqueId; RefreshType = refreshType; + //set default - this value is not used for reading after it's been deserialized, it's only used for persisting the instruction to the db + JsonIdCount = 1; } private RefreshInstruction(ICacheRefresher refresher, RefreshMethodType refreshType, Guid guidId) @@ -49,9 +56,21 @@ namespace Umbraco.Core.Sync IntId = intId; } - private RefreshInstruction(ICacheRefresher refresher, RefreshMethodType refreshType, string json) + /// + /// A private constructor to create a new instance + /// + /// + /// + /// + /// + /// When the refresh method is we know how many Ids are being refreshed so we know the instruction + /// count which will be taken into account when we store this count in the database. + /// + private RefreshInstruction(ICacheRefresher refresher, RefreshMethodType refreshType, string json, int idCount = 1) : this(refresher, refreshType) { + JsonIdCount = idCount; + if (refreshType == RefreshMethodType.RefreshByJson) JsonPayload = json; else @@ -76,8 +95,12 @@ namespace Umbraco.Core.Sync case MessageType.RefreshById: if (idType == null) throw new InvalidOperationException("Cannot refresh by id if idType is null."); - if (idType == typeof (int)) // bulk of ints is supported - return new[] { new RefreshInstruction(refresher, RefreshMethodType.RefreshByIds, JsonConvert.SerializeObject(ids.Cast().ToArray())) }; + if (idType == typeof(int)) + { + // bulk of ints is supported + var intIds = ids.Cast().ToArray(); + return new[] { new RefreshInstruction(refresher, RefreshMethodType.RefreshByIds, JsonConvert.SerializeObject(intIds), intIds.Length) }; + } // else must be guids, bulk of guids is not supported, iterate return ids.Select(x => new RefreshInstruction(refresher, RefreshMethodType.RefreshByGuid, (Guid) x)); @@ -120,6 +143,14 @@ namespace Umbraco.Core.Sync /// public string JsonIds { get; set; } + /// + /// Gets or sets the number of Ids contained in the JsonIds json value + /// + /// + /// This is used to determine the instruction count per row + /// + public int JsonIdCount { get; set; } + /// /// Gets or sets the payload data value. /// diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 434f25638e..7ffdaa909c 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -98,6 +98,7 @@ + @@ -140,10 +141,12 @@ + + @@ -317,7 +320,11 @@ + + + + @@ -335,9 +342,14 @@ + + + + + @@ -1276,6 +1288,7 @@ + @@ -1283,6 +1296,7 @@ +