diff --git a/.github/config/codeql-config.yml b/.github/config/codeql-config.yml index 432be995a5..77b390d392 100644 --- a/.github/config/codeql-config.yml +++ b/.github/config/codeql-config.yml @@ -5,3 +5,4 @@ paths: paths-ignore: - '**/node_modules' + - 'src/Umbraco.Web.UI/wwwroot' \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9ec8c3b4e3..33d3e851c7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -27,7 +27,7 @@ jobs: with: dotnet-version: '6.0.x' - - name: Build + - name: dotnet build run: dotnet build umbraco-netcore-only.sln # also runs npm build - name: Perform CodeQL Analysis diff --git a/src/JsonSchema/AppSettings.cs b/src/JsonSchema/AppSettings.cs index a9b70fc4a1..048513a5da 100644 --- a/src/JsonSchema/AppSettings.cs +++ b/src/JsonSchema/AppSettings.cs @@ -86,6 +86,8 @@ namespace JsonSchema public PackageMigrationSettings PackageMigration { get; set; } public LegacyPasswordMigrationSettings LegacyPasswordMigration { get; set; } + + public ContentDashboardSettings ContentDashboard { get; set; } } /// diff --git a/src/Umbraco.Core/Configuration/Models/CharItem.cs b/src/Umbraco.Core/Configuration/Models/CharItem.cs new file mode 100644 index 0000000000..e269e0a83e --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/CharItem.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.Configuration.UmbracoSettings; + +namespace Umbraco.Cms.Core.Configuration.Models +{ + public class CharItem : IChar + { + /// + /// The character to replace + /// + public string Char { get; set; } + + /// + /// The replacement character + /// + public string Replacement { get; set; } + } +} diff --git a/src/Umbraco.Core/Configuration/ContentDashboardSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs similarity index 55% rename from src/Umbraco.Core/Configuration/ContentDashboardSettings.cs rename to src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs index 7bef36dba4..3f8546a1ad 100644 --- a/src/Umbraco.Core/Configuration/ContentDashboardSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs @@ -1,6 +1,11 @@ - -namespace Umbraco.Cms.Core.Configuration +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models { + /// + /// Typed configuration options for content dashboard settings. + /// + [UmbracoOptions(Constants.Configuration.ConfigContentDashboard)] public class ContentDashboardSettings { private const string DefaultContentDashboardPath = "cms"; @@ -18,6 +23,13 @@ namespace Umbraco.Cms.Core.Configuration /// Gets the path to use when constructing the URL for retrieving data for the content dashboard. /// /// The URL path. - public string ContentDashboardPath { get; set; } = DefaultContentDashboardPath; + [DefaultValue(DefaultContentDashboardPath)] + public string ContentDashboardPath { get; set; } = DefaultContentDashboardPath; + + /// + /// Gets the allowed addresses to retrieve data for the content dashboard. + /// + /// The URLs. + public string[] ContentDashboardUrlAllowlist { get; set; } } } diff --git a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs index ee223b36c6..051c31dc26 100644 --- a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System; using System.Collections.Generic; using System.ComponentModel; using Umbraco.Cms.Core.Configuration.UmbracoSettings; @@ -16,33 +17,34 @@ namespace Umbraco.Cms.Core.Configuration.Models { internal const bool StaticAddTrailingSlash = true; internal const string StaticConvertUrlsToAscii = "try"; + internal const bool StaticEnableDefaultCharReplacements = true; internal static readonly CharItem[] DefaultCharCollection = { - new CharItem { Char = " ", Replacement = "-" }, - new CharItem { Char = "\"", Replacement = string.Empty }, - new CharItem { Char = "'", Replacement = string.Empty }, - new CharItem { Char = "%", Replacement = string.Empty }, - new CharItem { Char = ".", Replacement = string.Empty }, - new CharItem { Char = ";", Replacement = string.Empty }, - new CharItem { Char = "/", Replacement = string.Empty }, - new CharItem { Char = "\\", Replacement = string.Empty }, - new CharItem { Char = ":", Replacement = string.Empty }, - new CharItem { Char = "#", Replacement = string.Empty }, - new CharItem { Char = "+", Replacement = "plus" }, - new CharItem { Char = "*", Replacement = "star" }, - new CharItem { Char = "&", Replacement = string.Empty }, - new CharItem { Char = "?", Replacement = string.Empty }, - new CharItem { Char = "æ", Replacement = "ae" }, - new CharItem { Char = "ä", Replacement = "ae" }, - new CharItem { Char = "ø", Replacement = "oe" }, - new CharItem { Char = "ö", Replacement = "oe" }, - new CharItem { Char = "å", Replacement = "aa" }, - new CharItem { Char = "ü", Replacement = "ue" }, - new CharItem { Char = "ß", Replacement = "ss" }, - new CharItem { Char = "|", Replacement = "-" }, - new CharItem { Char = "<", Replacement = string.Empty }, - new CharItem { Char = ">", Replacement = string.Empty } + new () { Char = " ", Replacement = "-" }, + new () { Char = "\"", Replacement = string.Empty }, + new () { Char = "'", Replacement = string.Empty }, + new () { Char = "%", Replacement = string.Empty }, + new () { Char = ".", Replacement = string.Empty }, + new () { Char = ";", Replacement = string.Empty }, + new () { Char = "/", Replacement = string.Empty }, + new () { Char = "\\", Replacement = string.Empty }, + new () { Char = ":", Replacement = string.Empty }, + new () { Char = "#", Replacement = string.Empty }, + new () { Char = "+", Replacement = "plus" }, + new () { Char = "*", Replacement = "star" }, + new () { Char = "&", Replacement = string.Empty }, + new () { Char = "?", Replacement = string.Empty }, + new () { Char = "æ", Replacement = "ae" }, + new () { Char = "ä", Replacement = "ae" }, + new () { Char = "ø", Replacement = "oe" }, + new () { Char = "ö", Replacement = "oe" }, + new () { Char = "å", Replacement = "aa" }, + new () { Char = "ü", Replacement = "ue" }, + new () { Char = "ß", Replacement = "ss" }, + new () { Char = "|", Replacement = "-" }, + new () { Char = "<", Replacement = string.Empty }, + new () { Char = ">", Replacement = string.Empty } }; /// @@ -67,41 +69,21 @@ namespace Umbraco.Cms.Core.Configuration.Models /// public bool ShouldTryConvertUrlsToAscii => ConvertUrlsToAscii.InvariantEquals("try"); - // We need to special handle ":", as this character is special in keys - - // TODO: implement from configuration - - //// var collection = _configuration.GetSection(Prefix + "CharCollection").GetChildren() - //// .Select(x => new CharItem() - //// { - //// Char = x.GetValue("Char"), - //// Replacement = x.GetValue("Replacement"), - //// }).ToArray(); - - //// if (collection.Any() || _configuration.GetSection("Prefix").GetChildren().Any(x => - //// x.Key.Equals("CharCollection", StringComparison.OrdinalIgnoreCase))) - //// { - //// return collection; - //// } - - //// return DefaultCharCollection; + /// + /// Disable all default character replacements + /// + [DefaultValue(StaticEnableDefaultCharReplacements)] + public bool EnableDefaultCharReplacements { get; set; } = StaticEnableDefaultCharReplacements; /// - /// Gets or sets a value for the default character collection for replacements. + /// Add additional character replacements, or override defaults /// - /// WB-TODO + [Obsolete("Use the GetCharReplacements extension method in the Umbraco.Extensions namespace instead. Scheduled for removal in V11")] public IEnumerable CharCollection { get; set; } = DefaultCharCollection; /// - /// Defines a character replacement. + /// Add additional character replacements, or override defaults /// - public class CharItem : IChar - { - /// - public string Char { get; set; } - - /// - public string Replacement { get; set; } - } + public IEnumerable UserDefinedCharCollection { get; set; } } } diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs new file mode 100644 index 0000000000..a916febb93 --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using Umbraco.Cms.Core.Configuration.Models; + +namespace Umbraco.Cms.Core.Configuration.UmbracoSettings +{ + public class CharacterReplacementEqualityComparer : IEqualityComparer + { + public bool Equals(IChar x, IChar y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null) + { + return false; + } + + if (y is null) + { + return false; + } + + if (x.GetType() != y.GetType()) + { + return false; + } + + return x.Char == y.Char && x.Replacement == y.Replacement; + } + + public int GetHashCode(IChar obj) + { + unchecked + { + return ((obj.Char != null ? obj.Char.GetHashCode() : 0) * 397) ^ (obj.Replacement != null ? obj.Replacement.GetHashCode() : 0); + } + } + } +} diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs index 4073a12149..61e840245c 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs @@ -1,8 +1,9 @@ -namespace Umbraco.Cms.Core.Configuration.UmbracoSettings +namespace Umbraco.Cms.Core.Configuration.UmbracoSettings { public interface IChar { string Char { get; } + string Replacement { get; } } } diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index 063d733821..ab951618e3 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Core +namespace Umbraco.Cms.Core { public static partial class Constants { @@ -54,6 +54,7 @@ public const string ConfigUserPassword = ConfigPrefix + "Security:UserPassword"; public const string ConfigRichTextEditor = ConfigPrefix + "RichTextEditor"; public const string ConfigPackageMigration = ConfigPrefix + "PackageMigration"; + public const string ConfigContentDashboard = ConfigPrefix + "ContentDashboard"; } } } diff --git a/src/Umbraco.Core/Constants-Security.cs b/src/Umbraco.Core/Constants-Security.cs index b509c12ff5..68601a78b0 100644 --- a/src/Umbraco.Core/Constants-Security.cs +++ b/src/Umbraco.Core/Constants-Security.cs @@ -50,6 +50,7 @@ namespace Umbraco.Cms.Core /// providers need to be setup differently and each auth type for the back office will be prefixed with this value /// public const string BackOfficeExternalAuthenticationTypePrefix = "Umbraco."; + public const string MemberExternalAuthenticationTypePrefix = "UmbracoMembers."; public const string StartContentNodeIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/startcontentnode"; public const string StartMediaNodeIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/startmedianode"; diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index ce2e4f2304..f0cbf7f95d 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using System.Reflection; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Configuration.Models.Validation; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.DependencyInjection { @@ -82,7 +85,10 @@ namespace Umbraco.Cms.Core.DependencyInjection .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() - .AddUmbracoOptions(); + .AddUmbracoOptions() + .AddUmbracoOptions(); + + builder.Services.Configure(options => options.MergeReplacements(builder.Config)); return builder; } diff --git a/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs b/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs index 9b3674b07b..bceddf1fd6 100644 --- a/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs +++ b/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs @@ -62,6 +62,19 @@ namespace Umbraco.Extensions return username; } + public static string GetEmail(this IIdentity identity) + { + if (identity == null) throw new ArgumentNullException(nameof(identity)); + + string email = null; + if (identity is ClaimsIdentity claimsIdentity) + { + email = claimsIdentity.FindFirstValue(ClaimTypes.Email); + } + + return email; + } + /// /// Returns the first claim value found in the for the given claimType /// diff --git a/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs b/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs new file mode 100644 index 0000000000..e9e6618f8c --- /dev/null +++ b/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Configuration.UmbracoSettings; + +namespace Umbraco.Extensions +{ + /// + /// Get concatenated user and default character replacements + /// taking into account + /// + public static class RequestHandlerSettingsExtension + { + /// + /// Get concatenated user and default character replacements + /// taking into account + /// + public static IEnumerable GetCharReplacements(this RequestHandlerSettings requestHandlerSettings) + { + if (requestHandlerSettings.EnableDefaultCharReplacements is false) + { + return requestHandlerSettings.UserDefinedCharCollection ?? Enumerable.Empty(); + } + + if (requestHandlerSettings.UserDefinedCharCollection == null || requestHandlerSettings.UserDefinedCharCollection.Any() is false) + { + return RequestHandlerSettings.DefaultCharCollection; + } + + return MergeUnique(requestHandlerSettings.UserDefinedCharCollection, RequestHandlerSettings.DefaultCharCollection); + } + + /// + /// Merges CharCollection and UserDefinedCharCollection, prioritizing UserDefinedCharCollection + /// + internal static void MergeReplacements(this RequestHandlerSettings requestHandlerSettings, IConfiguration configuration) + { + string sectionKey = $"{Constants.Configuration.ConfigRequestHandler}:"; + + IEnumerable charCollection = GetReplacements( + configuration, + $"{sectionKey}{nameof(RequestHandlerSettings.CharCollection)}"); + + IEnumerable userDefinedCharCollection = GetReplacements( + configuration, + $"{sectionKey}{nameof(requestHandlerSettings.UserDefinedCharCollection)}"); + + IEnumerable mergedCollection = MergeUnique(userDefinedCharCollection, charCollection); + + requestHandlerSettings.UserDefinedCharCollection = mergedCollection; + } + + private static IEnumerable GetReplacements(IConfiguration configuration, string key) + { + var replacements = new List(); + IEnumerable config = configuration.GetSection(key).GetChildren(); + + foreach (IConfigurationSection section in config) + { + var @char = section.GetValue(nameof(CharItem.Char)); + var replacement = section.GetValue(nameof(CharItem.Replacement)); + replacements.Add(new CharItem { Char = @char, Replacement = replacement }); + } + + return replacements; + } + + /// + /// Merges two IEnumerable of CharItem without any duplicates, items in priorityReplacements will override those in alternativeReplacements + /// + private static IEnumerable MergeUnique( + IEnumerable priorityReplacements, + IEnumerable alternativeReplacements) + { + var priorityReplacementsList = priorityReplacements.ToList(); + var alternativeReplacementsList = alternativeReplacements.ToList(); + + foreach (CharItem alternativeReplacement in alternativeReplacementsList) + { + foreach (CharItem priorityReplacement in priorityReplacementsList) + { + if (priorityReplacement.Char == alternativeReplacement.Char) + { + alternativeReplacement.Replacement = priorityReplacement.Replacement; + } + } + } + + return priorityReplacementsList.Union( + alternativeReplacementsList, + new CharacterReplacementEqualityComparer()); + } + } +} diff --git a/src/Umbraco.Core/Extensions/TypeExtensions.cs b/src/Umbraco.Core/Extensions/TypeExtensions.cs index 67a6dd1dce..c5bc99cae8 100644 --- a/src/Umbraco.Core/Extensions/TypeExtensions.cs +++ b/src/Umbraco.Core/Extensions/TypeExtensions.cs @@ -430,10 +430,16 @@ namespace Umbraco.Extensions where T : Attribute { if (type == null) return Enumerable.Empty(); - return type.GetCustomAttributes(typeof (T), inherited).OfType(); + return type.GetCustomAttributes(typeof(T), inherited).OfType(); } - /// + public static bool HasCustomAttribute(this Type type, bool inherit) + where T : Attribute + { + return type.GetCustomAttribute(inherit) != null; + } + + /// /// Tries to return a value based on a property name for an object but ignores case sensitivity /// /// diff --git a/src/Umbraco.Core/Models/ITwoFactorLogin.cs b/src/Umbraco.Core/Models/ITwoFactorLogin.cs new file mode 100644 index 0000000000..ca005309b2 --- /dev/null +++ b/src/Umbraco.Core/Models/ITwoFactorLogin.cs @@ -0,0 +1,12 @@ +using System; +using Umbraco.Cms.Core.Models.Entities; + +namespace Umbraco.Cms.Core.Models +{ + public interface ITwoFactorLogin: IEntity, IRememberBeingDirty + { + string ProviderName { get; } + string Secret { get; } + Guid UserOrMemberKey { get; } + } +} diff --git a/src/Umbraco.Core/Models/PropertyTagsExtensions.cs b/src/Umbraco.Core/Models/PropertyTagsExtensions.cs index 390f644831..7168f99078 100644 --- a/src/Umbraco.Core/Models/PropertyTagsExtensions.cs +++ b/src/Umbraco.Core/Models/PropertyTagsExtensions.cs @@ -68,12 +68,15 @@ namespace Umbraco.Extensions switch (storageType) { case TagsStorageType.Csv: - property.SetValue(string.Join(delimiter.ToString(), currentTags.Union(trimmedTags)), culture); // csv string + property.SetValue(string.Join(delimiter.ToString(), currentTags.Union(trimmedTags)).NullOrWhiteSpaceAsNull(), culture); // csv string break; case TagsStorageType.Json: + var updatedTags = currentTags.Union(trimmedTags).ToArray(); + var updatedValue = updatedTags.Length == 0 ? null : serializer.Serialize(updatedTags); + property.SetValue(updatedValue, culture); // json array + break; property.SetValue(serializer.Serialize(currentTags.Union(trimmedTags).ToArray()), culture); // json array - break; } } else @@ -81,7 +84,7 @@ namespace Umbraco.Extensions switch (storageType) { case TagsStorageType.Csv: - property.SetValue(string.Join(delimiter.ToString(), trimmedTags), culture); // csv string + property.SetValue(string.Join(delimiter.ToString(), trimmedTags).NullOrWhiteSpaceAsNull(), culture); // csv string break; case TagsStorageType.Json: @@ -124,11 +127,13 @@ namespace Umbraco.Extensions switch (storageType) { case TagsStorageType.Csv: - property.SetValue(string.Join(delimiter.ToString(), currentTags.Except(trimmedTags)), culture); // csv string + property.SetValue(string.Join(delimiter.ToString(), currentTags.Except(trimmedTags)).NullOrWhiteSpaceAsNull(), culture); // csv string break; case TagsStorageType.Json: - property.SetValue(serializer.Serialize(currentTags.Except(trimmedTags).ToArray()), culture); // json array + var updatedTags = currentTags.Except(trimmedTags).ToArray(); + var updatedValue = updatedTags.Length == 0 ? null : serializer.Serialize(updatedTags); + property.SetValue(updatedValue, culture); // json array break; } } @@ -160,7 +165,7 @@ namespace Umbraco.Extensions case TagsStorageType.Json: try { - return serializer.Deserialize(value).Select(x => x.ToString().Trim()); + return serializer.Deserialize(value).Select(x => x.Trim()); } catch (Exception) { diff --git a/src/Umbraco.Core/Models/TwoFactorLogin.cs b/src/Umbraco.Core/Models/TwoFactorLogin.cs new file mode 100644 index 0000000000..6ede9606e8 --- /dev/null +++ b/src/Umbraco.Core/Models/TwoFactorLogin.cs @@ -0,0 +1,13 @@ +using System; +using Umbraco.Cms.Core.Models.Entities; + +namespace Umbraco.Cms.Core.Models +{ + public class TwoFactorLogin : EntityBase, ITwoFactorLogin + { + public string ProviderName { get; set; } + public string Secret { get; set; } + public Guid UserOrMemberKey { get; set; } + public bool Confirmed { get; set; } + } +} diff --git a/src/Umbraco.Core/Notifications/MemberTwoFactorRequestedNotification.cs b/src/Umbraco.Core/Notifications/MemberTwoFactorRequestedNotification.cs new file mode 100644 index 0000000000..980a531ffd --- /dev/null +++ b/src/Umbraco.Core/Notifications/MemberTwoFactorRequestedNotification.cs @@ -0,0 +1,14 @@ +using System; + +namespace Umbraco.Cms.Core.Notifications +{ + public class MemberTwoFactorRequestedNotification : INotification + { + public MemberTwoFactorRequestedNotification(Guid memberKey) + { + MemberKey = memberKey; + } + + public Guid MemberKey { get; } + } +} diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStartedNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStartedNotification.cs new file mode 100644 index 0000000000..a3d38720d7 --- /dev/null +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStartedNotification.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Notifications +{ + /// + /// Notification that occurs when Umbraco has completely booted up and the request processing pipeline is configured. + /// + /// + public class UmbracoApplicationStartedNotification : INotification + { } +} diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs index 4cbf0a55c6..dd60f9431c 100644 --- a/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs @@ -1,23 +1,23 @@ -// Copyright (c) Umbraco. -// See LICENSE for more details. - namespace Umbraco.Cms.Core.Notifications { /// - /// Notification that occurs at the very end of the Umbraco boot - /// process and after all initialize. + /// Notification that occurs at the very end of the Umbraco boot process (after all s are initialized). /// + /// public class UmbracoApplicationStartingNotification : INotification { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The runtime level public UmbracoApplicationStartingNotification(RuntimeLevel runtimeLevel) => RuntimeLevel = runtimeLevel; /// - /// Gets the runtime level of execution. + /// Gets the runtime level. /// + /// + /// The runtime level. + /// public RuntimeLevel RuntimeLevel { get; } } } diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs new file mode 100644 index 0000000000..be4c6ccfd4 --- /dev/null +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Notifications +{ + /// + /// Notification that occurs when Umbraco has completely shutdown. + /// + /// + public class UmbracoApplicationStoppedNotification : INotification + { } +} diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs index db86a1e614..6d5234bbcc 100644 --- a/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs @@ -1,4 +1,9 @@ namespace Umbraco.Cms.Core.Notifications { - public class UmbracoApplicationStoppingNotification : INotification { } + /// + /// Notification that occurs when Umbraco is shutting down (after all s are terminated). + /// + /// + public class UmbracoApplicationStoppingNotification : INotification + { } } diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 37560b4c0a..de5b8c04ae 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -55,6 +55,7 @@ namespace Umbraco.Cms.Core public const string UserGroup2Node = TableNamePrefix + "UserGroup2Node"; public const string UserGroup2NodePermission = TableNamePrefix + "UserGroup2NodePermission"; public const string ExternalLogin = TableNamePrefix + "ExternalLogin"; + public const string TwoFactorLogin = TableNamePrefix + "TwoFactorLogin"; public const string ExternalLoginToken = TableNamePrefix + "ExternalLoginToken"; public const string Macro = /*TableNamePrefix*/ "cms" + "Macro"; diff --git a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs index a685ab67f1..7d9594a3c6 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs @@ -1,15 +1,19 @@ +using System; using System.Collections.Generic; using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Core.Persistence.Repositories { + public interface IExternalLoginRepository : IReadWriteQueryRepository, IQueryRepository { + /// /// Replaces all external login providers for the user /// /// /// + [Obsolete("Use method that takes guid as param from IExternalLoginWithKeyRepository")] void Save(int userId, IEnumerable logins); /// @@ -17,8 +21,9 @@ namespace Umbraco.Cms.Core.Persistence.Repositories /// /// /// + [Obsolete("Use method that takes guid as param from IExternalLoginWithKeyRepository")] void Save(int userId, IEnumerable tokens); - + [Obsolete("Use method that takes guid as param from IExternalLoginWithKeyRepository")] void DeleteUserLogins(int memberId); } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs new file mode 100644 index 0000000000..0a4b9e76cf --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Persistence.Repositories +{ + + /// + /// Repository for external logins with Guid as key, so it can be shared for members and users + /// + public interface IExternalLoginWithKeyRepository : IReadWriteQueryRepository, IQueryRepository + { + /// + /// Replaces all external login providers for the user/member key + /// + void Save(Guid userOrMemberKey, IEnumerable logins); + + /// + /// Replaces all external login provider tokens for the providers specified for the user/member key + /// + void Save(Guid userOrMemberKey, IEnumerable tokens); + + /// + /// Deletes all external logins for the specified the user/member key + /// + void DeleteUserLogins(Guid userOrMemberKey); + } +} diff --git a/src/Umbraco.Core/Persistence/Repositories/ITwoFactorLoginRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ITwoFactorLoginRepository.cs new file mode 100644 index 0000000000..63622f8e82 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/ITwoFactorLoginRepository.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories +{ + public interface ITwoFactorLoginRepository: IReadRepository, IWriteRepository + { + Task DeleteUserLoginsAsync(Guid userOrMemberKey); + Task DeleteUserLoginsAsync(Guid userOrMemberKey, string providerName); + + Task> GetByUserOrMemberKeyAsync(Guid userOrMemberKey); + } + +} diff --git a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs index 07607be2b0..6d3e40067e 100644 --- a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs @@ -197,6 +197,7 @@ namespace Umbraco.Cms.Core.PropertyEditors default: throw new ArgumentOutOfRangeException(); } + return value.TryConvertTo(valueType); } @@ -232,6 +233,7 @@ namespace Umbraco.Cms.Core.PropertyEditors StaticApplicationLogging.Logger.LogWarning("The value {EditorValue} cannot be converted to the type {StorageTypeValue}", editorValue.Value, ValueTypes.ToStorageType(ValueType)); return null; } + return result.Result; } diff --git a/src/Umbraco.Core/Routing/DefaultUrlProvider.cs b/src/Umbraco.Core/Routing/DefaultUrlProvider.cs index 2a7bc97650..cc86fff004 100644 --- a/src/Umbraco.Core/Routing/DefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/DefaultUrlProvider.cs @@ -156,7 +156,7 @@ namespace Umbraco.Cms.Core.Routing : DomainUtilities.DomainForNode(umbracoContext.PublishedSnapshot.Domains, _siteDomainMapper, int.Parse(route.Substring(0, pos), CultureInfo.InvariantCulture), current, culture); var defaultCulture = _localizationService.GetDefaultLanguageIsoCode(); - if (domainUri is not null || culture == defaultCulture || culture is null) + if (domainUri is not null || culture == defaultCulture || string.IsNullOrEmpty(culture)) { var url = AssembleUrl(domainUri, path, current, mode).ToString(); return UrlInfo.Url(url, culture); diff --git a/src/Umbraco.Core/Runtime/MainDom.cs b/src/Umbraco.Core/Runtime/MainDom.cs index ec4e56df1b..08d11db5cd 100644 --- a/src/Umbraco.Core/Runtime/MainDom.cs +++ b/src/Umbraco.Core/Runtime/MainDom.cs @@ -154,11 +154,11 @@ namespace Umbraco.Cms.Core.Runtime // the handler is not installed so that would be the hosting environment if (_signaled) { - _logger.LogInformation("Cannot acquire (signaled)."); + _logger.LogInformation("Cannot acquire MainDom (signaled)."); return false; } - _logger.LogInformation("Acquiring."); + _logger.LogInformation("Acquiring MainDom."); // Get the lock var acquired = false; @@ -168,12 +168,12 @@ namespace Umbraco.Cms.Core.Runtime } catch (Exception ex) { - _logger.LogError(ex, "Error while acquiring"); + _logger.LogError(ex, "Error while acquiring MainDom"); } if (!acquired) { - _logger.LogInformation("Cannot acquire (timeout)."); + _logger.LogInformation("Cannot acquire MainDom (timeout)."); // In previous versions we'd let a TimeoutException be thrown // and the appdomain would not start. We have the opportunity to allow it to @@ -209,7 +209,7 @@ namespace Umbraco.Cms.Core.Runtime _logger.LogWarning(ex, ex.Message); } - _logger.LogInformation("Acquired."); + _logger.LogInformation("Acquired MainDom."); return true; } diff --git a/src/Umbraco.Core/Security/IIdentityUserLogin.cs b/src/Umbraco.Core/Security/IIdentityUserLogin.cs index 67ca739509..4e18771a17 100644 --- a/src/Umbraco.Core/Security/IIdentityUserLogin.cs +++ b/src/Umbraco.Core/Security/IIdentityUserLogin.cs @@ -19,7 +19,7 @@ namespace Umbraco.Cms.Core.Security string ProviderKey { get; set; } /// - /// Gets or sets user Id for the user who owns this login + /// Gets or sets user or member key (Guid) for the user/member who owns this login /// string UserId { get; set; } // TODO: This should be able to be used by both users and members diff --git a/src/Umbraco.Core/Services/IExternalLoginService.cs b/src/Umbraco.Core/Services/IExternalLoginService.cs index 787631d500..75f8069f0c 100644 --- a/src/Umbraco.Core/Services/IExternalLoginService.cs +++ b/src/Umbraco.Core/Services/IExternalLoginService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Umbraco.Cms.Core.Security; @@ -6,6 +7,7 @@ namespace Umbraco.Cms.Core.Services /// /// Used to store the external login info /// + [Obsolete("Use IExternalLoginServiceWithKey. This will be removed in Umbraco 10")] public interface IExternalLoginService : IService { /// diff --git a/src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs b/src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs new file mode 100644 index 0000000000..bc31f54f8b --- /dev/null +++ b/src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Services +{ + public interface IExternalLoginWithKeyService : IService + { + /// + /// Returns all user logins assigned + /// + IEnumerable GetExternalLogins(Guid userOrMemberKey); + + /// + /// Returns all user login tokens assigned + /// + IEnumerable GetExternalLoginTokens(Guid userOrMemberKey); + + /// + /// Returns all logins matching the login info - generally there should only be one but in some cases + /// there might be more than one depending on if an administrator has been editing/removing members + /// + IEnumerable Find(string loginProvider, string providerKey); + + /// + /// Saves the external logins associated with the user + /// + /// + /// The user or member key associated with the logins + /// + /// + /// + /// This will replace all external login provider information for the user + /// + void Save(Guid userOrMemberKey, IEnumerable logins); + + /// + /// Saves the external login tokens associated with the user + /// + /// + /// The user or member key associated with the logins + /// + /// + /// + /// This will replace all external login tokens for the user + /// + void Save(Guid userOrMemberKey,IEnumerable tokens); + + /// + /// Deletes all user logins - normally used when a member is deleted + /// + void DeleteUserLogins(Guid userOrMemberKey); + } +} diff --git a/src/Umbraco.Core/Services/ITwoFactorLoginService.cs b/src/Umbraco.Core/Services/ITwoFactorLoginService.cs new file mode 100644 index 0000000000..dd11f864fb --- /dev/null +++ b/src/Umbraco.Core/Services/ITwoFactorLoginService.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services +{ + public interface ITwoFactorLoginService : IService + { + /// + /// Deletes all user logins - normally used when a member is deleted + /// + Task DeleteUserLoginsAsync(Guid userOrMemberKey); + + Task IsTwoFactorEnabledAsync(Guid userKey); + Task GetSecretForUserAndProviderAsync(Guid userKey, string providerName); + + Task GetSetupInfoAsync(Guid userOrMemberKey, string providerName); + + IEnumerable GetAllProviderNames(); + Task DisableAsync(Guid userOrMemberKey, string providerName); + + bool ValidateTwoFactorSetup(string providerName, string secret, string code); + Task SaveAsync(TwoFactorLogin twoFactorLogin); + Task> GetEnabledTwoFactorProviderNamesAsync(Guid userOrMemberKey); + } + +} diff --git a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs index cf5e71a568..b0f0a9b003 100644 --- a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs +++ b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Configuration.UmbracoSettings; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Strings @@ -60,7 +61,9 @@ namespace Umbraco.Cms.Core.Strings /// The short string helper. public DefaultShortStringHelperConfig WithDefault(RequestHandlerSettings requestHandlerSettings) { - UrlReplaceCharacters = requestHandlerSettings.CharCollection + IEnumerable charCollection = requestHandlerSettings.GetCharReplacements(); + + UrlReplaceCharacters = charCollection .Where(x => string.IsNullOrEmpty(x.Char) == false) .ToDictionary(x => x.Char, x => x.Replacement); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index d3ebb28f9c..f9dc43cbd5 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -1,5 +1,7 @@ +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; using Umbraco.Extensions; @@ -29,7 +31,10 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); - builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(factory => factory.GetRequiredService()); + builder.Services.AddUnique(factory => factory.GetRequiredService()); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index 7d74ff13c8..aeec82a94e 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -8,10 +8,13 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Implement; using Umbraco.Cms.Infrastructure.Packaging; @@ -64,7 +67,15 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); - builder.Services.AddUnique(); + builder.Services.AddUnique(factory => new ExternalLoginService( + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService() + )); + builder.Services.AddUnique(factory => factory.GetRequiredService()); + builder.Services.AddUnique(factory => factory.GetRequiredService()); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddTransient(SourcesFactory); diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index 6a6652bc5d..52c86f9ccf 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -60,6 +60,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Install typeof(CacheInstructionDto), typeof(ExternalLoginDto), typeof(ExternalLoginTokenDto), + typeof(TwoFactorLoginDto), typeof(RedirectUrlDto), typeof(LockDto), typeof(UserGroupDto), diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 2b6f2fe6d6..2080034554 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -15,6 +15,7 @@ using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_8_9_0; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_1_0; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_2_0; +using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_3_0; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade @@ -268,7 +269,14 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade // TO 9.2.0 To("{0571C395-8F0B-44E9-8E3F-47BDD08D817B}"); To("{AD3D3B7F-8E74-45A4-85DB-7FFAD57F9243}"); + + + + // TO 9.3.0 To("{A2F22F17-5870-4179-8A8D-2362AA4A0A5F}"); + To("{CA7A1D9D-C9D4-4914-BC0A-459E7B9C3C8C}"); + To("{0828F206-DCF7-4F73-ABBB-6792275532EB}"); + } } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/AddTwoFactorLoginTable.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/AddTwoFactorLoginTable.cs new file mode 100644 index 0000000000..c5e569282a --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/AddTwoFactorLoginTable.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_3_0 +{ + public class AddTwoFactorLoginTable : MigrationBase + { + public AddTwoFactorLoginTable(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + IEnumerable tables = SqlSyntax.GetTablesInSchema(Context.Database); + if (tables.InvariantContains(TwoFactorLoginDto.TableName)) + { + return; + } + + Create.Table().Do(); + } + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_2_0/MovePackageXMLToDb.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/MovePackageXMLToDb.cs similarity index 97% rename from src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_2_0/MovePackageXMLToDb.cs rename to src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/MovePackageXMLToDb.cs index e4a4af0cbb..3d003eb31d 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_2_0/MovePackageXMLToDb.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/MovePackageXMLToDb.cs @@ -9,7 +9,7 @@ using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Extensions; -namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_2_0 +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_3_0 { public class MovePackageXMLToDb : MigrationBase { diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/UpdateExternalLoginToUseKeyInsteadOfId.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/UpdateExternalLoginToUseKeyInsteadOfId.cs new file mode 100644 index 0000000000..4c7104e762 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/UpdateExternalLoginToUseKeyInsteadOfId.cs @@ -0,0 +1,77 @@ +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_3_0 +{ + public class UpdateExternalLoginToUseKeyInsteadOfId : MigrationBase + { + public UpdateExternalLoginToUseKeyInsteadOfId(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + if (!ColumnExists(ExternalLoginDto.TableName, "userOrMemberKey")) + { + var indexNameToRecreate = "IX_" + ExternalLoginDto.TableName + "_LoginProvider"; + var indexNameToDelete = "IX_" + ExternalLoginDto.TableName + "_userId"; + + if (IndexExists(indexNameToRecreate)) + { + // drop it since the previous migration index was wrong, and we + // need to modify a column that belons to it + Delete.Index(indexNameToRecreate).OnTable(ExternalLoginDto.TableName).Do(); + } + + if (IndexExists(indexNameToDelete)) + { + // drop it since the previous migration index was wrong, and we + // need to modify a column that belons to it + Delete.Index(indexNameToDelete).OnTable(ExternalLoginDto.TableName).Do(); + } + + //special trick to add the column without constraints and return the sql to add them later + AddColumn("userOrMemberKey", out var sqls); + + + if (DatabaseType.IsSqlCe()) + { + var userIds = Database.Fetch(Sql().Select("userId").From()); + + foreach (int userId in userIds) + { + Execute.Sql($"UPDATE {ExternalLoginDto.TableName} SET userOrMemberKey = '{userId.ToGuid()}' WHERE userId = {userId}").Do(); + } + } + else + { + //populate the new columns with the userId as a Guid. Same method as IntExtensions.ToGuid. + Execute.Sql($"UPDATE {ExternalLoginDto.TableName} SET userOrMemberKey = CAST(CONVERT(char(8), CONVERT(BINARY(4), userId), 2) + '-0000-0000-0000-000000000000' AS UNIQUEIDENTIFIER)").Do(); + + } + + //now apply constraints (NOT NULL) to new table + foreach (var sql in sqls) Execute.Sql(sql).Do(); + + //now remove these old columns + Delete.Column("userId").FromTable(ExternalLoginDto.TableName).Do(); + + // create index with the correct definition + Create + .Index(indexNameToRecreate) + .OnTable(ExternalLoginDto.TableName) + .OnColumn("loginProvider").Ascending() + .OnColumn("userOrMemberKey").Ascending() + .WithOptions() + .Unique() + .WithOptions() + .NonClustered() + .Do(); + } + } + + + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs index 69bf1b837e..0af1ff83c5 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs @@ -16,13 +16,13 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos [PrimaryKeyColumn] public int Id { get; set; } - // TODO: This is completely missing a FK!!? ... IIRC that is because we want to change this to a GUID - // to support both members and users for external logins and that will not have any referential integrity - // This should be part of the members task for enabling external logins. + [Obsolete("This only exists to ensure you can upgrade using external logins from umbraco version where this was used to the new where it is not used")] + [ResultColumn("userId")] + public int? UserId { get; set; } - [Column("userId")] + [Column("userOrMemberKey")] [Index(IndexTypes.NonClustered)] - public int UserId { get; set; } + public Guid UserOrMemberKey { get; set; } /// /// Used to store the name of the provider (i.e. Facebook, Google) @@ -30,7 +30,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos [Column("loginProvider")] [Length(400)] [NullSetting(NullSetting = NullSettings.NotNull)] - [Index(IndexTypes.UniqueNonClustered, ForColumns = "loginProvider,userId", Name = "IX_" + TableName + "_LoginProvider")] + [Index(IndexTypes.UniqueNonClustered, ForColumns = "loginProvider,userOrMemberKey", Name = "IX_" + TableName + "_LoginProvider")] public string LoginProvider { get; set; } /// diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/TwoFactorLoginDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/TwoFactorLoginDto.cs new file mode 100644 index 0000000000..1202fe2a19 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/TwoFactorLoginDto.cs @@ -0,0 +1,33 @@ +using System; +using NPoco; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos +{ + [TableName(TableName)] + [ExplicitColumns] + [PrimaryKey("Id")] + internal class TwoFactorLoginDto + { + public const string TableName = Cms.Core.Constants.DatabaseSchema.Tables.TwoFactorLogin; + + [Column("id")] + [PrimaryKeyColumn] + public int Id { get; set; } + + [Column("userOrMemberKey")] + [Index(IndexTypes.NonClustered)] + public Guid UserOrMemberKey { get; set; } + + [Column("providerName")] + [Length(400)] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Index(IndexTypes.UniqueNonClustered, ForColumns = "providerName,userOrMemberKey", Name = "IX_" + TableName + "_ProviderName")] + public string ProviderName { get; set; } + + [Column("secret")] + [Length(400)] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string Secret { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs index 82bbb4a40a..1c74dcb8bd 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs @@ -2,6 +2,7 @@ using System; using System.Globalization; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Persistence.Factories { @@ -9,7 +10,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories { public static IIdentityUserToken BuildEntity(ExternalLoginTokenDto dto) { - var entity = new IdentityUserToken(dto.Id, dto.ExternalLoginDto.LoginProvider, dto.Name, dto.Value, dto.ExternalLoginDto.UserId.ToString(CultureInfo.InvariantCulture), dto.CreateDate); + var entity = new IdentityUserToken(dto.Id, dto.ExternalLoginDto.LoginProvider, dto.Name, dto.Value, dto.ExternalLoginDto.UserOrMemberKey.ToString(), dto.CreateDate); // reset dirty initial properties (U4-1946) entity.ResetDirtyProperties(false); @@ -18,7 +19,12 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories public static IIdentityUserLogin BuildEntity(ExternalLoginDto dto) { - var entity = new IdentityUserLogin(dto.Id, dto.LoginProvider, dto.ProviderKey, dto.UserId.ToString(CultureInfo.InvariantCulture), dto.CreateDate) + + //If there exists a UserId - this means the database is still not migrated. E.g on the upgrade state. + //At this point we have to manually set the key, to ensure external logins can be used to upgrade + var key = dto.UserId.HasValue ? dto.UserId.Value.ToGuid().ToString() : dto.UserOrMemberKey.ToString(); + + var entity = new IdentityUserLogin(dto.Id, dto.LoginProvider, dto.ProviderKey, key, dto.CreateDate) { UserData = dto.UserData }; @@ -36,19 +42,19 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories CreateDate = entity.CreateDate, LoginProvider = entity.LoginProvider, ProviderKey = entity.ProviderKey, - UserId = int.Parse(entity.UserId, CultureInfo.InvariantCulture), // TODO: This is temp until we change the ext logins to use GUIDs + UserOrMemberKey = entity.Key, UserData = entity.UserData }; return dto; } - public static ExternalLoginDto BuildDto(int userId, IExternalLogin entity, int? id = null) + public static ExternalLoginDto BuildDto(Guid userOrMemberKey, IExternalLogin entity, int? id = null) { var dto = new ExternalLoginDto { Id = id ?? default, - UserId = userId, + UserOrMemberKey = userOrMemberKey, LoginProvider = entity.LoginProvider, ProviderKey = entity.ProviderKey, UserData = entity.UserData, diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginMapper.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginMapper.cs index 2d47746baa..85db7bf553 100644 --- a/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginMapper.cs +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginMapper.cs @@ -18,7 +18,8 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Mappers DefineMap(nameof(IdentityUserLogin.CreateDate), nameof(ExternalLoginDto.CreateDate)); DefineMap(nameof(IdentityUserLogin.LoginProvider), nameof(ExternalLoginDto.LoginProvider)); DefineMap(nameof(IdentityUserLogin.ProviderKey), nameof(ExternalLoginDto.ProviderKey)); - DefineMap(nameof(IdentityUserLogin.UserId), nameof(ExternalLoginDto.UserId)); + DefineMap(nameof(IdentityUserLogin.Key), nameof(ExternalLoginDto.UserOrMemberKey)); + DefineMap(nameof(IdentityUserLogin.UserData), nameof(ExternalLoginDto.UserData)); } } } diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginTokenMapper.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginTokenMapper.cs index 4d03031ffd..ca8360c626 100644 --- a/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginTokenMapper.cs +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginTokenMapper.cs @@ -19,7 +19,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Mappers DefineMap(nameof(IdentityUserToken.Name), nameof(ExternalLoginTokenDto.Name)); DefineMap(nameof(IdentityUserToken.Value), nameof(ExternalLoginTokenDto.Value)); // separate table - DefineMap(nameof(IdentityUserToken.UserId), nameof(ExternalLoginDto.UserId)); + DefineMap(nameof(IdentityUserLogin.Key), nameof(ExternalLoginDto.UserOrMemberKey)); } } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs index 814bfefc88..bc6af5a09c 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs @@ -6,7 +6,6 @@ using NPoco; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models.Entities; -using Umbraco.Cms.Core.Persistence; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; @@ -18,22 +17,34 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement { - // TODO: We should update this to support both users and members. It means we would remove referential integrity from users - // and the user/member key would be a GUID (we also need to add a GUID to users) - internal class ExternalLoginRepository : EntityRepositoryBase, IExternalLoginRepository + internal class ExternalLoginRepository : EntityRepositoryBase, IExternalLoginRepository, IExternalLoginWithKeyRepository { public ExternalLoginRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger) : base(scopeAccessor, cache, logger) { } - public void DeleteUserLogins(int memberId) => Database.Delete("WHERE userId=@userId", new { userId = memberId }); + /// + [Obsolete("Use method that takes guid as param")] + public void DeleteUserLogins(int memberId) => DeleteUserLogins(memberId.ToGuid()); - public void Save(int userId, IEnumerable logins) + /// + [Obsolete("Use method that takes guid as param")] + public void Save(int userId, IEnumerable logins) => Save(userId.ToGuid(), logins); + + /// + [Obsolete("Use method that takes guid as param")] + public void Save(int userId, IEnumerable tokens) => Save(userId.ToGuid(), tokens); + + /// + public void DeleteUserLogins(Guid userOrMemberKey) => Database.Delete("WHERE userOrMemberKey=@userOrMemberKey", new { userOrMemberKey }); + + /// + public void Save(Guid userOrMemberKey, IEnumerable logins) { var sql = Sql() .Select() .From() - .Where(x => x.UserId == userId) + .Where(x => x.UserOrMemberKey == userOrMemberKey) .ForUpdate(); // deduplicate the logins @@ -71,10 +82,10 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement foreach (var u in toUpdate) { - Database.Update(ExternalLoginFactory.BuildDto(userId, u.Value, u.Key)); + Database.Update(ExternalLoginFactory.BuildDto(userOrMemberKey, u.Value, u.Key)); } - Database.InsertBulk(toInsert.Select(i => ExternalLoginFactory.BuildDto(userId, i))); + Database.InsertBulk(toInsert.Select(i => ExternalLoginFactory.BuildDto(userOrMemberKey, i))); } protected override IIdentityUserLogin PerformGet(int id) @@ -217,11 +228,12 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement return Database.ExecuteScalar(sql); } - public void Save(int userId, IEnumerable tokens) + /// + public void Save(Guid userOrMemberKey, IEnumerable tokens) { // get the existing logins (provider + id) var existingUserLogins = Database - .Fetch(GetBaseQuery(false).Where(x => x.UserId == userId)) + .Fetch(GetBaseQuery(false).Where(x => x.UserOrMemberKey == userOrMemberKey)) .ToDictionary(x => x.LoginProvider, x => x.Id); // deduplicate the tokens @@ -231,7 +243,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement Sql sql = GetBaseTokenQuery(true) .WhereIn(x => x.LoginProvider, providers) - .Where(x => x.UserId == userId); + .Where(x => x.UserOrMemberKey == userOrMemberKey); var toUpdate = new Dictionary(); var toDelete = new List(); @@ -289,7 +301,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement .On(x => x.ExternalLoginId, x => x.Id) : Sql() .Select() - .AndSelect(x => x.LoginProvider, x => x.UserId) + .AndSelect(x => x.LoginProvider, x => x.UserOrMemberKey) .From() .InnerJoin() .On(x => x.ExternalLoginId, x => x.Id); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TwoFactorLoginRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TwoFactorLoginRepository.cs new file mode 100644 index 0000000000..18063edf16 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TwoFactorLoginRepository.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NPoco; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Querying; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement +{ + internal class TwoFactorLoginRepository : EntityRepositoryBase, ITwoFactorLoginRepository + { + public TwoFactorLoginRepository(IScopeAccessor scopeAccessor, AppCaches cache, + ILogger logger) + : base(scopeAccessor, cache, logger) + { + } + + + protected override Sql GetBaseQuery(bool isCount) + { + var sql = SqlContext.Sql(); + + sql = isCount + ? sql.SelectCount() + : sql.Select(); + + sql.From(); + + return sql; + } + + protected override string GetBaseWhereClause() => + Core.Constants.DatabaseSchema.Tables.TwoFactorLogin + ".id = @id"; + + protected override IEnumerable GetDeleteClauses() => Enumerable.Empty(); + + protected override ITwoFactorLogin PerformGet(int id) + { + var sql = GetBaseQuery(false).Where(x => x.Id == id); + var dto = Database.Fetch(sql).FirstOrDefault(); + return dto == null ? null : Map(dto); + } + + protected override IEnumerable PerformGetAll(params int[] ids) + { + var sql = GetBaseQuery(false).WhereIn(x => x.Id, ids); + var dtos = Database.Fetch(sql); + return dtos.WhereNotNull().Select(Map); + } + + protected override IEnumerable PerformGetByQuery(IQuery query) + { + var sqlClause = GetBaseQuery(false); + var translator = new SqlTranslator(sqlClause, query); + var sql = translator.Translate(); + return Database.Fetch(sql).Select(Map); + } + + protected override void PersistNewItem(ITwoFactorLogin entity) + { + var dto = Map(entity); + Database.Insert(dto); + } + + protected override void PersistUpdatedItem(ITwoFactorLogin entity) + { + var dto = Map(entity); + Database.Update(dto); + } + + private static TwoFactorLoginDto Map(ITwoFactorLogin entity) + { + if (entity == null) return null; + + return new TwoFactorLoginDto + { + Id = entity.Id, + UserOrMemberKey = entity.UserOrMemberKey, + ProviderName = entity.ProviderName, + Secret = entity.Secret, + }; + } + + private static ITwoFactorLogin Map(TwoFactorLoginDto dto) + { + if (dto == null) return null; + + return new TwoFactorLogin + { + Id = dto.Id, + UserOrMemberKey = dto.UserOrMemberKey, + ProviderName = dto.ProviderName, + Secret = dto.Secret, + }; + } + + public async Task DeleteUserLoginsAsync(Guid userOrMemberKey) + { + return await DeleteUserLoginsAsync(userOrMemberKey, null); + } + + public async Task DeleteUserLoginsAsync(Guid userOrMemberKey, string providerName) + { + var sql = Sql() + .Delete() + .From() + .Where(x => x.UserOrMemberKey == userOrMemberKey); + + if (providerName is not null) + { + sql = sql.Where(x => x.ProviderName == providerName); + } + + var deletedRows = await Database.ExecuteAsync(sql); + + return deletedRows > 0; + } + + public async Task> GetByUserOrMemberKeyAsync(Guid userOrMemberKey) + { + var sql = Sql() + .Select() + .From() + .Where(x => x.UserOrMemberKey == userOrMemberKey); + var dtos = await Database.FetchAsync(sql); + return dtos.WhereNotNull().Select(Map); + } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs index b4b46f2f7c..7a2d626fcd 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs @@ -238,7 +238,7 @@ namespace Umbraco.Cms.Core.PropertyEditors MapBlockItemData(blockEditorData.BlockValue.SettingsData); // return json - return JsonConvert.SerializeObject(blockEditorData.BlockValue); + return JsonConvert.SerializeObject(blockEditorData.BlockValue, Formatting.None); } #endregion diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyHandler.cs index cfa1c4b3cb..7248a7f5b0 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyHandler.cs @@ -52,7 +52,7 @@ namespace Umbraco.Cms.Core.PropertyEditors UpdateBlockListRecursively(blockListValue, createGuid); - return JsonConvert.SerializeObject(blockListValue.BlockValue); + return JsonConvert.SerializeObject(blockListValue.BlockValue, Formatting.None); } private void UpdateBlockListRecursively(BlockEditorData blockListData, Func createGuid) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs index 36f11f5ce8..0c9cd40995 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs @@ -6,6 +6,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Runtime.Serialization; using System.Text.RegularExpressions; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Serialization; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs index e4039b6cee..ac7d5a4ef4 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs @@ -2,6 +2,8 @@ // See LICENSE for more details. using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; @@ -41,11 +43,13 @@ namespace Umbraco.Cms.Core.PropertyEditors foreach (var cultureVal in propVals) { // Remove keys from published value & any nested properties - var updatedPublishedVal = FormatPropertyValue(cultureVal.PublishedValue?.ToString(), onlyMissingKeys); + var publishedValue = cultureVal.PublishedValue is JToken jsonPublishedValue ? jsonPublishedValue.ToString(Formatting.None) : cultureVal.PublishedValue?.ToString(); + var updatedPublishedVal = FormatPropertyValue(publishedValue, onlyMissingKeys).NullOrWhiteSpaceAsNull(); cultureVal.PublishedValue = updatedPublishedVal; // Remove keys from edited/draft value & any nested properties - var updatedEditedVal = FormatPropertyValue(cultureVal.EditedValue?.ToString(), onlyMissingKeys); + var editedValue = cultureVal.EditedValue is JToken jsonEditedValue ? jsonEditedValue.ToString(Formatting.None) : cultureVal.EditedValue?.ToString(); + var updatedEditedVal = FormatPropertyValue(editedValue, onlyMissingKeys).NullOrWhiteSpaceAsNull(); cultureVal.EditedValue = updatedEditedVal; } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs index e3a6987110..52a1e50fc4 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs @@ -139,7 +139,7 @@ namespace Umbraco.Cms.Core.PropertyEditors } // Convert back to raw JSON for persisting - return JsonConvert.SerializeObject(grid); + return JsonConvert.SerializeObject(grid, Formatting.None); } /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs index 8afdb42419..be3bc3b707 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs @@ -213,7 +213,7 @@ namespace Umbraco.Cms.Core.PropertyEditors var sourcePath = _mediaFileManager.FileSystem.GetRelativePath(src); var copyPath = _mediaFileManager.CopyFile(notification.Copy, property.PropertyType, sourcePath); jo["src"] = _mediaFileManager.FileSystem.GetUrl(copyPath); - notification.Copy.SetValue(property.Alias, jo.ToString(), propertyValue.Culture, propertyValue.Segment); + notification.Copy.SetValue(property.Alias, jo.ToString(Formatting.None), propertyValue.Culture, propertyValue.Segment); isUpdated = true; } } @@ -274,17 +274,11 @@ namespace Umbraco.Cms.Core.PropertyEditors // it can happen when an image is uploaded via the folder browser, in which case // the property value will be the file source eg '/media/23454/hello.jpg' and we // are fixing that anomaly here - does not make any sense at all but... bah... - - var dt = _dataTypeService.GetDataType(property.PropertyType.DataTypeId); - var config = dt?.ConfigurationAs(); src = svalue; - var json = new + property.SetValue(JsonConvert.SerializeObject(new { - src = svalue, - crops = config == null ? Array.Empty() : config.Crops - }; - - property.SetValue(JsonConvert.SerializeObject(json), pvalue.Culture, pvalue.Segment); + src = svalue + }, Formatting.None), pvalue.Culture, pvalue.Segment); } else { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs index 4fa41bc7d3..b2f6852cee 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs @@ -206,6 +206,10 @@ namespace Umbraco.Cms.Core.PropertyEditors { src = val, crops = crops + },new JsonSerializerSettings() + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore }); } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index 45aa507a54..2cfe5dd56e 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -12,6 +12,7 @@ using Newtonsoft.Json; using System; using System.Linq; using System.Runtime.Serialization; +using Newtonsoft.Json.Linq; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors @@ -53,23 +54,55 @@ namespace Umbraco.Cms.Core.PropertyEditors internal class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference { private readonly IJsonSerializer _jsonSerializer; + private readonly IDataTypeService _dataTypeService; public MediaPicker3PropertyValueEditor( ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, - DataEditorAttribute attribute) + DataEditorAttribute attribute, + IDataTypeService dataTypeService) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _jsonSerializer = jsonSerializer; + _dataTypeService = dataTypeService; } public override object ToEditor(IProperty property, string culture = null, string segment = null) { var value = property.GetValue(culture, segment); - return Deserialize(_jsonSerializer, value); + var dtos = Deserialize(_jsonSerializer, value).ToList(); + + var dataType = _dataTypeService.GetDataType(property.PropertyType.DataTypeId); + if (dataType?.Configuration != null) + { + var configuration = dataType.ConfigurationAs(); + + foreach (var dto in dtos) + { + dto.ApplyConfiguration(configuration); + } + } + + return dtos; + } + + public override object FromEditor(ContentPropertyData editorValue, object currentValue) + { + if (editorValue.Value is JArray dtos) + { + // Clean up redundant/default data + foreach (var dto in dtos.Values()) + { + MediaWithCropsDto.Prune(dto); + } + + return dtos.ToString(Formatting.None); + } + + return base.FromEditor(editorValue, currentValue); } /// @@ -142,6 +175,52 @@ namespace Umbraco.Cms.Core.PropertyEditors [DataMember(Name = "focalPoint")] public ImageCropperValue.ImageCropperFocalPoint FocalPoint { get; set; } + + + /// + /// Applies the configuration to ensure only valid crops are kept and have the correct width/height. + /// + /// The configuration. + public void ApplyConfiguration(MediaPicker3Configuration configuration) + { + var crops = new List(); + + var configuredCrops = configuration?.Crops; + if (configuredCrops != null) + { + foreach (var configuredCrop in configuredCrops) + { + var crop = Crops?.FirstOrDefault(x => x.Alias == configuredCrop.Alias); + + crops.Add(new ImageCropperValue.ImageCropperCrop + { + Alias = configuredCrop.Alias, + Width = configuredCrop.Width, + Height = configuredCrop.Height, + Coordinates = crop?.Coordinates + }); + } + } + + Crops = crops; + + if (configuration?.EnableLocalFocalPoint == false) + { + FocalPoint = null; + } + } + + /// + /// Removes redundant crop data/default focal point. + /// + /// The media with crops DTO. + /// + /// The cleaned up value. + /// + /// + /// Because the DTO uses the same JSON keys as the image cropper value for crops and focal point, we can re-use the prune method. + /// + public static void Prune(JObject value) => ImageCropperValue.Prune(value); } } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs index c1d8aa33f8..f6d8a598b0 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs @@ -141,6 +141,7 @@ namespace Umbraco.Cms.Core.PropertyEditors private static readonly JsonSerializerSettings LinkDisplayJsonSerializerSettings = new JsonSerializerSettings { + Formatting = Formatting.None, NullValueHandling = NullValueHandling.Ignore }; @@ -150,13 +151,17 @@ namespace Umbraco.Cms.Core.PropertyEditors if (string.IsNullOrEmpty(value)) { - return string.Empty; + return null; } try { + var links = JsonConvert.DeserializeObject>(value); + if (links.Count == 0) + return null; + return JsonConvert.SerializeObject( - from link in JsonConvert.DeserializeObject>(value) + from link in links select new MultiUrlPickerValueEditor.LinkDto { Name = link.Name, @@ -164,8 +169,8 @@ namespace Umbraco.Cms.Core.PropertyEditors Target = link.Target, Udi = link.Udi, Url = link.Udi == null ? link.Url : null, // only save the URL for external links - }, LinkDisplayJsonSerializerSettings - ); + }, + LinkDisplayJsonSerializerSettings); } catch (Exception ex) { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs index c3a5bae383..97cb677d4c 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs @@ -80,7 +80,7 @@ namespace Umbraco.Cms.Core.PropertyEditors public override object FromEditor(ContentPropertyData editorValue, object currentValue) { var asArray = editorValue.Value as JArray; - if (asArray == null) + if (asArray == null || asArray.HasValues == false) { return null; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultipleValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultipleValueEditor.cs index c0e544b50c..47f8c9a169 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultipleValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultipleValueEditor.cs @@ -59,14 +59,19 @@ namespace Umbraco.Cms.Core.PropertyEditors public override object FromEditor(ContentPropertyData editorValue, object currentValue) { var json = editorValue.Value as JArray; - if (json == null) + if (json == null || json.HasValues == false) { return null; } var values = json.Select(item => item.Value()).ToArray(); - return JsonConvert.SerializeObject(values); + if (values.Length == 0) + { + return null; + } + + return JsonConvert.SerializeObject(values, Formatting.None); } } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs index 80ed34e6e1..835431820c 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs @@ -105,7 +105,7 @@ namespace Umbraco.Cms.Core.PropertyEditors var rows = _nestedContentValues.GetPropertyValues(propertyValue); if (rows.Count == 0) - return string.Empty; + return null; foreach (var row in rows.ToList()) { @@ -136,7 +136,7 @@ namespace Umbraco.Cms.Core.PropertyEditors } } - return JsonConvert.SerializeObject(rows).ToXmlString(); + return JsonConvert.SerializeObject(rows, Formatting.None).ToXmlString(); } #endregion @@ -231,7 +231,7 @@ namespace Umbraco.Cms.Core.PropertyEditors var rows = _nestedContentValues.GetPropertyValues(editorValue.Value); if (rows.Count == 0) - return string.Empty; + return null; foreach (var row in rows.ToList()) { @@ -256,7 +256,7 @@ namespace Umbraco.Cms.Core.PropertyEditors } // return json - return JsonConvert.SerializeObject(rows); + return JsonConvert.SerializeObject(rows, Formatting.None); } #endregion diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyHandler.cs index 1bc5dd2f4b..aa825bb0f8 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyHandler.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Extensions; @@ -32,7 +33,7 @@ namespace Umbraco.Cms.Core.PropertyEditors UpdateNestedContentKeysRecursively(complexEditorValue, onlyMissingKeys, createGuid); - return complexEditorValue.ToString(); + return complexEditorValue.ToString(Formatting.None); } private void UpdateNestedContentKeysRecursively(JToken json, bool onlyMissingKeys, Func createGuid) @@ -65,7 +66,7 @@ namespace Umbraco.Cms.Core.PropertyEditors var parsed = JToken.Parse(propVal); UpdateNestedContentKeysRecursively(parsed, onlyMissingKeys, createGuid); // set the value to the updated one - prop.Value = parsed.ToString(); + prop.Value = parsed.ToString(Formatting.None); } } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index 50b6d7a881..1f05da3bde 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -157,7 +157,7 @@ namespace Umbraco.Cms.Core.PropertyEditors var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages); var parsed = MacroTagParser.FormatRichTextContentForPersistence(editorValueWithMediaUrlsRemoved); - return parsed; + return parsed.NullOrWhiteSpaceAsNull(); } /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs index 4683851936..42f6424bfa 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs @@ -73,7 +73,7 @@ namespace Umbraco.Cms.Core.PropertyEditors if (editorValue.Value is JArray json) { - return json.Select(x => x.Value()); + return json.HasValues ? json.Select(x => x.Value()) : null; } if (string.IsNullOrWhiteSpace(value) == false) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValue.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValue.cs index c0efaac4ae..97f1b8398c 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValue.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValue.cs @@ -7,6 +7,7 @@ using System.ComponentModel; using System.Linq; using System.Runtime.Serialization; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Strings; @@ -122,13 +123,19 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters /// /// public bool HasFocalPoint() - => FocalPoint != null && (FocalPoint.Left != 0.5m || FocalPoint.Top != 0.5m); + => FocalPoint is ImageCropperFocalPoint focalPoint && (focalPoint.Left != 0.5m || focalPoint.Top != 0.5m); + + /// + /// Determines whether the value has crops. + /// + public bool HasCrops() + => Crops is IEnumerable crops && crops.Any(); /// /// Determines whether the value has a specified crop. /// public bool HasCrop(string alias) - => Crops != null && Crops.Any(x => x.Alias == alias); + => Crops is IEnumerable crops && crops.Any(x => x.Alias == alias); /// /// Determines whether the value has a source image. @@ -167,6 +174,51 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters }; } + /// + /// Removes redundant crop data/default focal point. + /// + /// The image cropper value. + /// + /// The cleaned up value. + /// + public static void Prune(JObject value) + { + if (value is null) throw new ArgumentNullException(nameof(value)); + + if (value.TryGetValue("crops", out var crops)) + { + if (crops.HasValues) + { + foreach (var crop in crops.Values().ToList()) + { + if (crop.TryGetValue("coordinates", out var coordinates) == false || coordinates.HasValues == false) + { + // Remove crop without coordinates + crop.Remove(); + continue; + } + + // Width/height are already stored in the crop configuration + crop.Remove("width"); + crop.Remove("height"); + } + } + + if (crops.HasValues == false) + { + // Remove empty crops + value.Remove("crops"); + } + } + + if (value.TryGetValue("focalPoint", out var focalPoint) && + (focalPoint.HasValues == false || (focalPoint.Value("top") == 0.5m && focalPoint.Value("left") == 0.5m))) + { + // Remove empty/default focal point + value.Remove("focalPoint"); + } + } + #region IEquatable /// diff --git a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs index 175bceb9e0..5dbe78c2f5 100644 --- a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs +++ b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs @@ -2,6 +2,8 @@ using System; using System.ComponentModel; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration; @@ -16,12 +18,13 @@ using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; using ComponentCollection = Umbraco.Cms.Core.Composing.ComponentCollection; +using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; namespace Umbraco.Cms.Infrastructure.Runtime { + /// public class CoreRuntime : IRuntime { - private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; private readonly ComponentCollection _components; private readonly IApplicationShutdownRegistry _applicationShutdownRegistry; @@ -32,14 +35,16 @@ namespace Umbraco.Cms.Infrastructure.Runtime private readonly IHostingEnvironment _hostingEnvironment; private readonly IUmbracoVersion _umbracoVersion; private readonly IServiceProvider _serviceProvider; + private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly ILogger _logger; private CancellationToken _cancellationToken; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// public CoreRuntime( - ILoggerFactory loggerFactory, IRuntimeState state, + ILoggerFactory loggerFactory, ComponentCollection components, IApplicationShutdownRegistry applicationShutdownRegistry, IProfilingLogger profilingLogger, @@ -48,9 +53,11 @@ namespace Umbraco.Cms.Infrastructure.Runtime IEventAggregator eventAggregator, IHostingEnvironment hostingEnvironment, IUmbracoVersion umbracoVersion, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + IHostApplicationLifetime hostApplicationLifetime) { State = state; + _loggerFactory = loggerFactory; _components = components; _applicationShutdownRegistry = applicationShutdownRegistry; @@ -61,6 +68,7 @@ namespace Umbraco.Cms.Infrastructure.Runtime _hostingEnvironment = hostingEnvironment; _umbracoVersion = umbracoVersion; _serviceProvider = serviceProvider; + _hostApplicationLifetime = hostApplicationLifetime; _logger = _loggerFactory.CreateLogger(); } @@ -76,23 +84,49 @@ namespace Umbraco.Cms.Infrastructure.Runtime IUmbracoDatabaseFactory databaseFactory, IEventAggregator eventAggregator, IHostingEnvironment hostingEnvironment, - IUmbracoVersion umbracoVersion - ):this( - loggerFactory, - state, - components, - applicationShutdownRegistry, - profilingLogger, - mainDom, - databaseFactory, - eventAggregator, - hostingEnvironment, - umbracoVersion, - null - ) - { + IUmbracoVersion umbracoVersion, + IServiceProvider serviceProvider) + : this( + state, + loggerFactory, + components, + applicationShutdownRegistry, + profilingLogger, + mainDom, + databaseFactory, + eventAggregator, + hostingEnvironment, + umbracoVersion, + serviceProvider, + serviceProvider?.GetRequiredService()) + { } - } + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete] + public CoreRuntime( + ILoggerFactory loggerFactory, + IRuntimeState state, + ComponentCollection components, + IApplicationShutdownRegistry applicationShutdownRegistry, + IProfilingLogger profilingLogger, + IMainDom mainDom, + IUmbracoDatabaseFactory databaseFactory, + IEventAggregator eventAggregator, + IHostingEnvironment hostingEnvironment, + IUmbracoVersion umbracoVersion) + : this( + loggerFactory, + state, + components, + applicationShutdownRegistry, + profilingLogger, + mainDom, + databaseFactory, + eventAggregator, + hostingEnvironment, + umbracoVersion, + null) + { } /// /// Gets the state of the Umbraco runtime. @@ -103,13 +137,17 @@ namespace Umbraco.Cms.Infrastructure.Runtime public async Task RestartAsync() { await StopAsync(_cancellationToken); + await _eventAggregator.PublishAsync(new UmbracoApplicationStoppedNotification(), _cancellationToken); await StartAsync(_cancellationToken); + await _eventAggregator.PublishAsync(new UmbracoApplicationStartedNotification(), _cancellationToken); } /// public async Task StartAsync(CancellationToken cancellationToken) { + // Store token, so we can re-use this during restart _cancellationToken = cancellationToken; + StaticApplicationLogging.Initialize(_loggerFactory); StaticServiceProvider.Instance = _serviceProvider; @@ -130,6 +168,13 @@ namespace Umbraco.Cms.Infrastructure.Runtime _logger.LogError(exception, msg); }; + // Add application started and stopped notifications (only on initial startup, not restarts) + if (_hostApplicationLifetime.ApplicationStarted.IsCancellationRequested == false) + { + _hostApplicationLifetime.ApplicationStarted.Register(() => _eventAggregator.Publish(new UmbracoApplicationStartedNotification())); + _hostApplicationLifetime.ApplicationStopped.Register(() => _eventAggregator.Publish(new UmbracoApplicationStoppedNotification())); + } + // acquire the main domain - if this fails then anything that should be registered with MainDom will not operate AcquireMainDom(); @@ -137,7 +182,7 @@ namespace Umbraco.Cms.Infrastructure.Runtime await _eventAggregator.PublishAsync(new UmbracoApplicationMainDomAcquiredNotification(), cancellationToken); // notify for unattended install - await _eventAggregator.PublishAsync(new RuntimeUnattendedInstallNotification()); + await _eventAggregator.PublishAsync(new RuntimeUnattendedInstallNotification(), cancellationToken); DetermineRuntimeLevel(); if (!State.UmbracoCanBoot()) @@ -153,7 +198,7 @@ namespace Umbraco.Cms.Infrastructure.Runtime // if level is Run and reason is UpgradeMigrations, that means we need to perform an unattended upgrade var unattendedUpgradeNotification = new RuntimeUnattendedUpgradeNotification(); - await _eventAggregator.PublishAsync(unattendedUpgradeNotification); + await _eventAggregator.PublishAsync(unattendedUpgradeNotification, cancellationToken); switch (unattendedUpgradeNotification.UnattendedUpgradeResult) { case RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors: @@ -161,6 +206,7 @@ namespace Umbraco.Cms.Infrastructure.Runtime { throw new InvalidOperationException($"Unattended upgrade result was {RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors} but no {nameof(BootFailedException)} was registered"); } + // we cannot continue here, the exception will be rethrown by BootFailedMiddelware return; case RuntimeUnattendedUpgradeNotification.UpgradeResult.CoreUpgradeComplete: diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs index ebd12719e1..df4d704781 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs @@ -159,5 +159,24 @@ namespace Umbraco.Cms.Core.Security } private static string UserIdToString(int userId) => string.Intern(userId.ToString(CultureInfo.InvariantCulture)); + + public Guid Key => UserIdToInt(Id).ToGuid(); + + + private static int UserIdToInt(string userId) + { + if(int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + + if(Guid.TryParse(userId, out var key)) + { + // Reverse the IntExtensions.ToGuid + return BitConverter.ToInt32(key.ToByteArray(), 0); + } + + throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); + } } } diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 3645115aa5..df05b1e6b5 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -8,6 +8,7 @@ using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; @@ -16,6 +17,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Security @@ -29,7 +31,7 @@ namespace Umbraco.Cms.Core.Security private readonly IScopeProvider _scopeProvider; private readonly IUserService _userService; private readonly IEntityService _entityService; - private readonly IExternalLoginService _externalLoginService; + private readonly IExternalLoginWithKeyService _externalLoginService; private readonly GlobalSettings _globalSettings; private readonly IUmbracoMapper _mapper; private readonly AppCaches _appCaches; @@ -37,11 +39,12 @@ namespace Umbraco.Cms.Core.Security /// /// Initializes a new instance of the class. /// + [ActivatorUtilitiesConstructor] public BackOfficeUserStore( IScopeProvider scopeProvider, IUserService userService, IEntityService entityService, - IExternalLoginService externalLoginService, + IExternalLoginWithKeyService externalLoginService, IOptionsSnapshot globalSettings, IUmbracoMapper mapper, BackOfficeErrorDescriber describer, @@ -59,6 +62,29 @@ namespace Umbraco.Cms.Core.Security _externalLoginService = externalLoginService; } + [Obsolete("Use ctor injecting IExternalLoginWithKeyService ")] + public BackOfficeUserStore( + IScopeProvider scopeProvider, + IUserService userService, + IEntityService entityService, + IExternalLoginService externalLoginService, + IOptions globalSettings, + IUmbracoMapper mapper, + BackOfficeErrorDescriber describer, + AppCaches appCaches) + : this( + scopeProvider, + userService, + entityService, + StaticServiceProvider.Instance.GetRequiredService(), + globalSettings, + mapper, + describer, + appCaches) + { + + } + /// public override Task CreateAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default) { @@ -104,7 +130,7 @@ namespace Umbraco.Cms.Core.Security if (isLoginsPropertyDirty) { _externalLoginService.Save( - userEntity.Id, + userEntity.Key, user.Logins.Select(x => new ExternalLogin( x.LoginProvider, x.ProviderKey, @@ -114,7 +140,7 @@ namespace Umbraco.Cms.Core.Security if (isTokensPropertyDirty) { _externalLoginService.Save( - userEntity.Id, + userEntity.Key, user.LoginTokens.Select(x => new ExternalLoginToken( x.LoginProvider, x.Name, @@ -156,7 +182,7 @@ namespace Umbraco.Cms.Core.Security if (isLoginsPropertyDirty) { _externalLoginService.Save( - found.Id, + found.Key, user.Logins.Select(x => new ExternalLogin( x.LoginProvider, x.ProviderKey, @@ -166,7 +192,7 @@ namespace Umbraco.Cms.Core.Security if (isTokensPropertyDirty) { _externalLoginService.Save( - found.Id, + found.Key, user.LoginTokens.Select(x => new ExternalLoginToken( x.LoginProvider, x.Name, @@ -190,13 +216,14 @@ namespace Umbraco.Cms.Core.Security throw new ArgumentNullException(nameof(user)); } - IUser found = _userService.GetUserById(UserIdToInt(user.Id)); + var userId = UserIdToInt(user.Id); + IUser found = _userService.GetUserById(userId); if (found != null) { _userService.Delete(found); } - _externalLoginService.DeleteUserLogins(UserIdToInt(user.Id)); + _externalLoginService.DeleteUserLogins(userId.ToGuid()); return Task.FromResult(IdentityResult.Success); } @@ -414,7 +441,7 @@ namespace Umbraco.Cms.Core.Security { if (user != null) { - var userId = UserIdToInt(user.Id); + var userId = UserIdToInt(user.Id).ToGuid(); user.SetLoginsCallback(new Lazy>(() => _externalLoginService.GetExternalLogins(userId))); user.SetTokensCallback(new Lazy>(() => _externalLoginService.GetExternalLoginTokens(userId))); } diff --git a/src/Umbraco.Infrastructure/Security/DeleteExternalLoginsOnMemberDeletedHandler.cs b/src/Umbraco.Infrastructure/Security/DeleteExternalLoginsOnMemberDeletedHandler.cs new file mode 100644 index 0000000000..b2b829d8e1 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/DeleteExternalLoginsOnMemberDeletedHandler.cs @@ -0,0 +1,30 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Security +{ + /// + /// Deletes the external logins for the deleted members. This cannot be handled by the database as there is not foreign keys. + /// + public class DeleteExternalLoginsOnMemberDeletedHandler : INotificationHandler + { + private readonly IExternalLoginWithKeyService _externalLoginWithKeyService; + + /// + /// Initializes a new instance of the class. + /// + public DeleteExternalLoginsOnMemberDeletedHandler(IExternalLoginWithKeyService externalLoginWithKeyService) + => _externalLoginWithKeyService = externalLoginWithKeyService; + + /// + public void Handle(MemberDeletedNotification notification) + { + foreach (IMember member in notification.DeletedEntities) + { + _externalLoginWithKeyService.DeleteUserLogins(member.Key); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/Security/DeleteTwoFactorLoginsOnMemberDeletedHandler.cs b/src/Umbraco.Infrastructure/Security/DeleteTwoFactorLoginsOnMemberDeletedHandler.cs new file mode 100644 index 0000000000..7fe4a7c506 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/DeleteTwoFactorLoginsOnMemberDeletedHandler.cs @@ -0,0 +1,33 @@ +using System.Threading; +using System.Threading.Tasks; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Security +{ + /// + /// Deletes the two factor for the deleted members. This cannot be handled by the database as there is not foreign keys. + /// + public class DeleteTwoFactorLoginsOnMemberDeletedHandler : INotificationAsyncHandler + { + private readonly ITwoFactorLoginService _twoFactorLoginService; + + /// + /// Initializes a new instance of the class. + /// + public DeleteTwoFactorLoginsOnMemberDeletedHandler(ITwoFactorLoginService twoFactorLoginService) + => _twoFactorLoginService = twoFactorLoginService; + + /// + public async Task HandleAsync(MemberDeletedNotification notification, CancellationToken cancellationToken) + { + foreach (IMember member in notification.DeletedEntities) + { + await _twoFactorLoginService.DeleteUserLoginsAsync(member.Key); + } + } + + } +} diff --git a/src/Umbraco.Infrastructure/Security/ITwoFactorProvider.cs b/src/Umbraco.Infrastructure/Security/ITwoFactorProvider.cs new file mode 100644 index 0000000000..f0da6c314a --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/ITwoFactorProvider.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading.Tasks; + +namespace Umbraco.Cms.Core.Security +{ + public interface ITwoFactorProvider + { + string ProviderName { get; } + + Task GetSetupDataAsync(Guid userOrMemberKey, string secret); + + bool ValidateTwoFactorPIN(string secret, string token); + + /// + /// + /// + /// Called to confirm the setup of two factor on the user. + bool ValidateTwoFactorSetup(string secret, string token); + } + + +} diff --git a/src/Umbraco.Infrastructure/Security/MemberIdentityBuilder.cs b/src/Umbraco.Infrastructure/Security/MemberIdentityBuilder.cs new file mode 100644 index 0000000000..c0df423638 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/MemberIdentityBuilder.cs @@ -0,0 +1,63 @@ +using System; +using System.Reflection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Umbraco.Cms.Core.Net; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.Security +{ + + public class MemberIdentityBuilder : IdentityBuilder + { + /// + /// Initializes a new instance of the class. + /// + public MemberIdentityBuilder(IServiceCollection services) + : base(typeof(MemberIdentityUser), services) + => InitializeServices(services); + + /// + /// Initializes a new instance of the class. + /// + public MemberIdentityBuilder(Type role, IServiceCollection services) + : base(typeof(MemberIdentityUser), role, services) + => InitializeServices(services); + + private void InitializeServices(IServiceCollection services) + { + + } + + // override to add itself, by default identity only wants a single IdentityErrorDescriber + public override IdentityBuilder AddErrorDescriber() + { + if (!typeof(MembersErrorDescriber).IsAssignableFrom(typeof(TDescriber))) + { + throw new InvalidOperationException($"The type {typeof(TDescriber)} does not inherit from {typeof(MembersErrorDescriber)}"); + } + + Services.AddScoped(); + return this; + } + + /// + /// Adds a token provider for the . + /// + /// The name of the provider to add. + /// The type of the to add. + /// The current instance. + public override IdentityBuilder AddTokenProvider(string providerName, Type provider) + { + if (!typeof(IUserTwoFactorTokenProvider<>).MakeGenericType(UserType).GetTypeInfo().IsAssignableFrom(provider.GetTypeInfo())) + { + throw new InvalidOperationException($"Invalid Type for TokenProvider: {provider.FullName}"); + } + + Services.Configure(options => options.Tokens.ProviderMap[providerName] = new TokenProviderDescriptor(provider)); + Services.AddTransient(provider); + return this; + } + } +} diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index d757cfb088..4fba880e81 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -6,12 +6,14 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Security @@ -26,6 +28,8 @@ namespace Umbraco.Cms.Core.Security private readonly IUmbracoMapper _mapper; private readonly IScopeProvider _scopeProvider; private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IExternalLoginWithKeyService _externalLoginService; + private readonly ITwoFactorLoginService _twoFactorLoginService; /// /// Initializes a new instance of the class for the members identity store @@ -34,18 +38,52 @@ namespace Umbraco.Cms.Core.Security /// The mapper for properties /// The scope provider /// The error describer + /// The published snapshot accessor + /// The external login service + /// The two factor login service + [ActivatorUtilitiesConstructor] public MemberUserStore( IMemberService memberService, IUmbracoMapper mapper, IScopeProvider scopeProvider, IdentityErrorDescriber describer, - IPublishedSnapshotAccessor publishedSnapshotAccessor) + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IExternalLoginWithKeyService externalLoginService, + ITwoFactorLoginService twoFactorLoginService + ) : base(describer) { _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); _scopeProvider = scopeProvider ?? throw new ArgumentNullException(nameof(scopeProvider)); _publishedSnapshotAccessor = publishedSnapshotAccessor; + _externalLoginService = externalLoginService; + _twoFactorLoginService = twoFactorLoginService; + } + + [Obsolete("Use ctor with IExternalLoginWithKeyService and ITwoFactorLoginService param")] + public MemberUserStore( + IMemberService memberService, + IUmbracoMapper mapper, + IScopeProvider scopeProvider, + IdentityErrorDescriber describer, + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IExternalLoginService externalLoginService) + : this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService()) + { + + } + + [Obsolete("Use ctor with IExternalLoginWithKeyService and ITwoFactorLoginService param")] + public MemberUserStore( + IMemberService memberService, + IUmbracoMapper mapper, + IScopeProvider scopeProvider, + IdentityErrorDescriber describer, + IPublishedSnapshotAccessor publishedSnapshotAccessor) + : this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService()) + { + } /// @@ -83,18 +121,29 @@ namespace Umbraco.Cms.Core.Security user.Id = UserIdToString(memberEntity.Id); user.Key = memberEntity.Key; - // [from backofficeuser] we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. - // var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MembersIdentityUser.Logins)); - // TODO: confirm re externallogins implementation - //if (isLoginsPropertyDirty) - //{ - // _externalLoginService.Save( - // user.Id, - // user.Logins.Select(x => new ExternalLogin( - // x.LoginProvider, - // x.ProviderKey, - // x.UserData))); - //} + // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. + var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.Logins)); + var isTokensPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.LoginTokens)); + + if (isLoginsPropertyDirty) + { + _externalLoginService.Save( + memberEntity.Key, + user.Logins.Select(x => new ExternalLogin( + x.LoginProvider, + x.ProviderKey, + x.UserData))); + } + + if (isTokensPropertyDirty) + { + _externalLoginService.Save( + memberEntity.Key, + user.LoginTokens.Select(x => new ExternalLoginToken( + x.LoginProvider, + x.Name, + x.Value))); + } return Task.FromResult(IdentityResult.Success); @@ -142,17 +191,15 @@ namespace Umbraco.Cms.Core.Security _memberService.SetLastLogin(found.Username, DateTime.Now); } - // TODO: when to implement external login service? - - //if (isLoginsPropertyDirty) - //{ - // _externalLoginService.Save( - // found.Id, - // user.Logins.Select(x => new ExternalLogin( - // x.LoginProvider, - // x.ProviderKey, - // x.UserData))); - //} + if (isLoginsPropertyDirty) + { + _externalLoginService.Save( + found.Key, + user.Logins.Select(x => new ExternalLogin( + x.LoginProvider, + x.ProviderKey, + x.UserData))); + } } return Task.FromResult(IdentityResult.Success); @@ -181,8 +228,7 @@ namespace Umbraco.Cms.Core.Security _memberService.Delete(found); } - // TODO: when to implement external login service? - //_externalLoginService.DeleteUserLogins(UserIdToInt(user.Id)); + _externalLoginService.DeleteUserLogins(user.Key); return Task.FromResult(IdentityResult.Success); } @@ -203,7 +249,8 @@ namespace Umbraco.Cms.Core.Security throw new ArgumentNullException(nameof(userId)); } - IMember user = _memberService.GetById(UserIdToInt(userId)); + + IMember user = Guid.TryParse(userId, out var key) ? _memberService.GetByKey(key) : _memberService.GetById(UserIdToInt(userId)); if (user == null) { return Task.FromResult((MemberIdentityUser)null); @@ -375,10 +422,7 @@ namespace Umbraco.Cms.Core.Security throw new ArgumentNullException(nameof(providerKey)); } - var logins = new List(); - - // TODO: external login needed - //_externalLoginService.Find(loginProvider, providerKey).ToList(); + var logins = _externalLoginService.Find(loginProvider, providerKey).ToList(); if (logins.Count == 0) { return Task.FromResult((IdentityUserLogin)null); @@ -492,8 +536,8 @@ namespace Umbraco.Cms.Core.Security { if (user != null) { - //TODO: implement - //user.SetLoginsCallback(new Lazy>(() => _externalLoginService.GetAll(UserIdToInt(user.Id)))); + user.SetLoginsCallback(new Lazy>(() => _externalLoginService.GetExternalLogins(user.Key))); + user.SetTokensCallback(new Lazy>(() => _externalLoginService.GetExternalLoginTokens(user.Key))); } return user; @@ -639,5 +683,34 @@ namespace Umbraco.Cms.Core.Security LoginOnly, FullSave } + + /// + /// Overridden to support Umbraco's own data storage requirements + /// + /// + /// The base class's implementation of this calls into FindTokenAsync, RemoveUserTokenAsync and AddUserTokenAsync, both methods will only work with ORMs that are change + /// tracking ORMs like EFCore. + /// + /// + public override Task GetTokenAsync(MemberIdentityUser user, string loginProvider, string name, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + IIdentityUserToken token = user.LoginTokens.FirstOrDefault(x => x.LoginProvider.InvariantEquals(loginProvider) && x.Name.InvariantEquals(name)); + + return Task.FromResult(token?.Value); + } + + /// + public override async Task GetTwoFactorEnabledAsync(MemberIdentityUser user, + CancellationToken cancellationToken = default(CancellationToken)) + { + return await _twoFactorLoginService.IsTwoFactorEnabledAsync(user.Key); + } } } diff --git a/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs b/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs index 0fee166013..111a05d816 100644 --- a/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs +++ b/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs @@ -263,5 +263,13 @@ namespace Umbraco.Cms.Core.Security return await VerifyPasswordAsync(userPasswordStore, user, password) == PasswordVerificationResult.Success; } + + /// + public virtual async Task> GetValidTwoFactorProvidersAsync(TUser user) + { + var results = await base.GetValidTwoFactorProvidersAsync(user); + + return results; + } } } diff --git a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs index 6a2325c316..aaaaed55e7 100644 --- a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs @@ -34,6 +34,12 @@ namespace Umbraco.Cms.Core.Security return result; } + if(Guid.TryParse(userId, out var key)) + { + // Reverse the IntExtensions.ToGuid + return BitConverter.ToInt32(key.ToByteArray(), 0); + } + throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); } diff --git a/src/Umbraco.Infrastructure/Serialization/ConfigurationEditorJsonSerializer.cs b/src/Umbraco.Infrastructure/Serialization/ConfigurationEditorJsonSerializer.cs index e8e32fe7da..ab978c903e 100644 --- a/src/Umbraco.Infrastructure/Serialization/ConfigurationEditorJsonSerializer.cs +++ b/src/Umbraco.Infrastructure/Serialization/ConfigurationEditorJsonSerializer.cs @@ -12,6 +12,8 @@ namespace Umbraco.Cms.Infrastructure.Serialization { JsonSerializerSettings.Converters.Add(new FuzzyBooleanConverter()); JsonSerializerSettings.ContractResolver = new ConfigurationCustomContractResolver(); + JsonSerializerSettings.Formatting = Formatting.None; + JsonSerializerSettings.NullValueHandling = NullValueHandling.Ignore; } private class ConfigurationCustomContractResolver : DefaultContractResolver diff --git a/src/Umbraco.Infrastructure/Serialization/JsonNetSerializer.cs b/src/Umbraco.Infrastructure/Serialization/JsonNetSerializer.cs index a728630680..5c5377c0a1 100644 --- a/src/Umbraco.Infrastructure/Serialization/JsonNetSerializer.cs +++ b/src/Umbraco.Infrastructure/Serialization/JsonNetSerializer.cs @@ -14,7 +14,8 @@ namespace Umbraco.Cms.Infrastructure.Serialization Converters = new List() { new StringEnumConverter() - } + }, + Formatting = Formatting.None }; public string Serialize(object input) { diff --git a/src/Umbraco.Infrastructure/Serialization/JsonToStringConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonToStringConverter.cs index e4c17ab918..2e7416b2d2 100644 --- a/src/Umbraco.Infrastructure/Serialization/JsonToStringConverter.cs +++ b/src/Umbraco.Infrastructure/Serialization/JsonToStringConverter.cs @@ -22,7 +22,7 @@ namespace Umbraco.Cms.Infrastructure.Serialization } // Load JObject from stream JObject jObject = JObject.Load(reader); - return jObject.ToString(); + return jObject.ToString(Formatting.None); } public override bool CanConvert(Type objectType) diff --git a/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs b/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs index 079971be24..589ac288c8 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs @@ -1,44 +1,77 @@ +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.DependencyInjection; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services.Implement { - public class ExternalLoginService : RepositoryService, IExternalLoginService + public class ExternalLoginService : RepositoryService, IExternalLoginService, IExternalLoginWithKeyService { - private readonly IExternalLoginRepository _externalLoginRepository; + private readonly IExternalLoginWithKeyRepository _externalLoginRepository; public ExternalLoginService(IScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, - IExternalLoginRepository externalLoginRepository) + IExternalLoginWithKeyRepository externalLoginRepository) : base(provider, loggerFactory, eventMessagesFactory) { _externalLoginRepository = externalLoginRepository; } + [Obsolete("Use ctor injecting IExternalLoginWithKeyRepository")] + public ExternalLoginService(IScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, + IExternalLoginRepository externalLoginRepository) + : this(provider, loggerFactory, eventMessagesFactory, StaticServiceProvider.Instance.GetRequiredService()) + { + } + /// + [Obsolete("Use overload that takes a user/member key (Guid).")] public IEnumerable GetExternalLogins(int userId) + => GetExternalLogins(userId.ToGuid()); + + /// + [Obsolete("Use overload that takes a user/member key (Guid).")] + public IEnumerable GetExternalLoginTokens(int userId) => + GetExternalLoginTokens(userId.ToGuid()); + + /// + [Obsolete("Use overload that takes a user/member key (Guid).")] + public void Save(int userId, IEnumerable logins) + => Save(userId.ToGuid(), logins); + + /// + [Obsolete("Use overload that takes a user/member key (Guid).")] + public void Save(int userId, IEnumerable tokens) + => Save(userId.ToGuid(), tokens); + + /// + [Obsolete("Use overload that takes a user/member key (Guid).")] + public void DeleteUserLogins(int userId) + => DeleteUserLogins(userId.ToGuid()); + + /// + public IEnumerable GetExternalLogins(Guid userOrMemberKey) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { - // TODO: This is temp until we update the external service to support guids for both users and members - var asString = userId.ToString(CultureInfo.InvariantCulture); - return _externalLoginRepository.Get(Query().Where(x => x.UserId == asString)) + return _externalLoginRepository.Get(Query().Where(x => x.Key == userOrMemberKey)) .ToList(); } } - public IEnumerable GetExternalLoginTokens(int userId) + /// + public IEnumerable GetExternalLoginTokens(Guid userOrMemberKey) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { - // TODO: This is temp until we update the external service to support guids for both users and members - var asString = userId.ToString(CultureInfo.InvariantCulture); - return _externalLoginRepository.Get(Query().Where(x => x.UserId == asString)) + return _externalLoginRepository.Get(Query().Where(x => x.Key == userOrMemberKey)) .ToList(); } } @@ -55,30 +88,31 @@ namespace Umbraco.Cms.Core.Services.Implement } /// - public void Save(int userId, IEnumerable logins) + public void Save(Guid userOrMemberKey, IEnumerable logins) { using (var scope = ScopeProvider.CreateScope()) { - _externalLoginRepository.Save(userId, logins); - scope.Complete(); - } - } - - public void Save(int userId, IEnumerable tokens) - { - using (var scope = ScopeProvider.CreateScope()) - { - _externalLoginRepository.Save(userId, tokens); + _externalLoginRepository.Save(userOrMemberKey, logins); scope.Complete(); } } /// - public void DeleteUserLogins(int userId) + public void Save(Guid userOrMemberKey, IEnumerable tokens) { using (var scope = ScopeProvider.CreateScope()) { - _externalLoginRepository.DeleteUserLogins(userId); + _externalLoginRepository.Save(userOrMemberKey, tokens); + scope.Complete(); + } + } + + /// + public void DeleteUserLogins(Guid userOrMemberKey) + { + using (var scope = ScopeProvider.CreateScope()) + { + _externalLoginRepository.DeleteUserLogins(userOrMemberKey); scope.Complete(); } } diff --git a/src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs b/src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs new file mode 100644 index 0000000000..713a73c1df --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Services +{ + public class TwoFactorLoginService : ITwoFactorLoginService + { + private readonly ITwoFactorLoginRepository _twoFactorLoginRepository; + private readonly IScopeProvider _scopeProvider; + private readonly IOptions _identityOptions; + private readonly IDictionary _twoFactorSetupGenerators; + + public TwoFactorLoginService( + ITwoFactorLoginRepository twoFactorLoginRepository, + IScopeProvider scopeProvider, + IEnumerable twoFactorSetupGenerators, + IOptions identityOptions) + { + _twoFactorLoginRepository = twoFactorLoginRepository; + _scopeProvider = scopeProvider; + _identityOptions = identityOptions; + _twoFactorSetupGenerators = twoFactorSetupGenerators.ToDictionary(x=>x.ProviderName); + } + + public async Task DeleteUserLoginsAsync(Guid userOrMemberKey) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + await _twoFactorLoginRepository.DeleteUserLoginsAsync(userOrMemberKey); + } + + public async Task> GetEnabledTwoFactorProviderNamesAsync(Guid userOrMemberKey) + { + return await GetEnabledProviderNamesAsync(userOrMemberKey); + } + + private async Task> GetEnabledProviderNamesAsync(Guid userOrMemberKey) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + var providersOnUser = (await _twoFactorLoginRepository.GetByUserOrMemberKeyAsync(userOrMemberKey)) + .Select(x => x.ProviderName).ToArray(); + + return providersOnUser.Where(x => _identityOptions.Value.Tokens.ProviderMap.ContainsKey(x)); + } + + + public async Task IsTwoFactorEnabledAsync(Guid userOrMemberKey) + { + return (await GetEnabledProviderNamesAsync(userOrMemberKey)).Any(); + } + + public async Task GetSecretForUserAndProviderAsync(Guid userOrMemberKey, string providerName) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + return (await _twoFactorLoginRepository.GetByUserOrMemberKeyAsync(userOrMemberKey)).FirstOrDefault(x=>x.ProviderName == providerName)?.Secret; + } + + public async Task GetSetupInfoAsync(Guid userOrMemberKey, string providerName) + { + var secret = await GetSecretForUserAndProviderAsync(userOrMemberKey, providerName); + + //Dont allow to generate a new secrets if user already has one + if (!string.IsNullOrEmpty(secret)) + { + return default; + } + + secret = GenerateSecret(); + + if (!_twoFactorSetupGenerators.TryGetValue(providerName, out ITwoFactorProvider generator)) + { + throw new InvalidOperationException($"No ITwoFactorSetupGenerator found for provider: {providerName}"); + } + + return await generator.GetSetupDataAsync(userOrMemberKey, secret); + } + + public IEnumerable GetAllProviderNames() => _twoFactorSetupGenerators.Keys; + public async Task DisableAsync(Guid userOrMemberKey, string providerName) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + return (await _twoFactorLoginRepository.DeleteUserLoginsAsync(userOrMemberKey, providerName)); + + } + + public bool ValidateTwoFactorSetup(string providerName, string secret, string code) + { + if (!_twoFactorSetupGenerators.TryGetValue(providerName, out ITwoFactorProvider generator)) + { + throw new InvalidOperationException($"No ITwoFactorSetupGenerator found for provider: {providerName}"); + } + + return generator.ValidateTwoFactorSetup(secret, code); + } + + public Task SaveAsync(TwoFactorLogin twoFactorLogin) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + _twoFactorLoginRepository.Save(twoFactorLogin); + + return Task.CompletedTask; + } + + + /// + /// Generates a new random unique secret. + /// + /// The random secret + protected virtual string GenerateSecret() => Guid.NewGuid().ToString(); + } +} diff --git a/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs b/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs index 5dfa1429d7..0282ac0612 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs @@ -636,7 +636,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers r = code }); - // Construct full URL using configured application URL (which will fall back to request) + // Construct full URL using configured application URL (which will fall back to current request) Uri applicationUri = _httpContextAccessor.GetRequiredHttpContext().Request.GetApplicationUri(_webRoutingSettings); var callbackUri = new Uri(applicationUri, action); return callbackUri.ToString(); diff --git a/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs b/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs index 0c6f798901..8255dcd977 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; @@ -8,9 +9,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Dashboards; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; @@ -66,6 +69,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers //we have just one instance of HttpClient shared for the entire application private static readonly HttpClient HttpClient = new HttpClient(); + // TODO(V10) : change return type to Task> and consider removing baseUrl as parameter //we have baseurl as a param to make previewing easier, so we can test with a dev domain from client side [ValidateAngularAntiForgeryToken] public async Task GetRemoteDashboardContent(string section, string baseUrl = "https://dashboard.umbraco.com/") @@ -76,6 +80,16 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers var version = _umbracoVersion.SemanticVersion.ToSemanticStringWithoutBuild(); var isAdmin = user.IsAdmin(); + if (!IsAllowedUrl(baseUrl)) + { + _logger.LogError($"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {baseUrl}"); + HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + + // Hacking the response - can't set the HttpContext.Response.Body, so instead returning the error as JSON + var errorJson = JsonConvert.SerializeObject(new { Error = "Dashboard source not permitted" }); + return JObject.Parse(errorJson); + } + var url = string.Format("{0}{1}?section={2}&allowed={3}&lang={4}&version={5}&admin={6}", baseUrl, _dashboardSettings.ContentDashboardPath, @@ -116,8 +130,15 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers return result; } + // TODO(V10) : consider removing baseUrl as parameter public async Task GetRemoteDashboardCss(string section, string baseUrl = "https://dashboard.umbraco.org/") { + if (!IsAllowedUrl(baseUrl)) + { + _logger.LogError($"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {baseUrl}"); + return BadRequest("Dashboard source not permitted"); + } + var url = string.Format(baseUrl + "css/dashboard.css?section={0}", section); var key = "umbraco-dynamic-dashboard-css-" + section; @@ -152,12 +173,18 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers } - return Content(result,"text/css", Encoding.UTF8); + return Content(result, "text/css", Encoding.UTF8); } public async Task GetRemoteXml(string site, string url) { + if (!IsAllowedUrl(url)) + { + _logger.LogError($"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {url}"); + return BadRequest("Dashboard source not permitted"); + } + // This is used in place of the old feedproxy.config // Which was used to grab data from our.umbraco.com, umbraco.com or umbraco.tv // for certain dashboards or the help drawer @@ -214,7 +241,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers } } - return Content(result,"text/xml", Encoding.UTF8); + return Content(result, "text/xml", Encoding.UTF8); } @@ -240,5 +267,19 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers }) }).ToList(); } + + // Checks if the passed URL is part of the configured allowlist of addresses + private bool IsAllowedUrl(string url) + { + // No addresses specified indicates that any URL is allowed + if (_dashboardSettings.ContentDashboardUrlAllowlist is null || _dashboardSettings.ContentDashboardUrlAllowlist.Contains(url, StringComparer.OrdinalIgnoreCase)) + { + return true; + } + else + { + return false; + } + } } } diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index 4e95236b5f..e9cc213598 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -2,9 +2,15 @@ using System; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Net; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.AspNetCore; using Umbraco.Cms.Web.Common.Security; @@ -27,7 +33,16 @@ namespace Umbraco.Extensions builder.BuildUmbracoBackOfficeIdentity() .AddDefaultTokenProviders() - .AddUserStore() + .AddUserStore, BackOfficeUserStore>(factory => new BackOfficeUserStore( + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService>(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService() + )) .AddUserManager() .AddSignInManager() .AddClaimsPrincipalFactory() diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs index 80a9d920a1..d62edcc1f9 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs @@ -62,6 +62,11 @@ namespace Umbraco.Cms.Web.BackOffice.Security { public void PostConfigure(string name, TOptions options) { + if (!name.StartsWith(Constants.Security.BackOfficeExternalAuthenticationTypePrefix)) + { + return; + } + options.SignInScheme = Constants.Security.BackOfficeExternalAuthenticationType; } } diff --git a/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs index 504c74d90e..1f5a7fad33 100644 --- a/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs @@ -3,6 +3,7 @@ using System.Runtime.Serialization; using Microsoft.AspNetCore.Identity; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.Security; using SecurityConstants = Umbraco.Cms.Core.Constants.Security; namespace Umbraco.Cms.Web.BackOffice.Security @@ -13,11 +14,12 @@ namespace Umbraco.Cms.Web.BackOffice.Security public class ExternalSignInAutoLinkOptions { /// - /// Creates a new instance + /// Initializes a new instance of the class. /// /// /// If null, the default will be the 'editor' group /// + /// public ExternalSignInAutoLinkOptions( bool autoLinkExternalAccount = false, string[] defaultUserGroups = null, @@ -30,12 +32,6 @@ namespace Umbraco.Cms.Web.BackOffice.Security _defaultCulture = defaultCulture; } - /// - /// By default this is true which allows the user to manually link and unlink the external provider, if set to false the back office user - /// will not see and cannot perform manual linking or unlinking of the external provider. - /// - public bool AllowManualLinking { get; } - /// /// A callback executed during account auto-linking and before the user is persisted /// @@ -50,10 +46,16 @@ namespace Umbraco.Cms.Web.BackOffice.Security public Func OnExternalLogin { get; set; } /// - /// Flag indicating if logging in with the external provider should auto-link/create a local user + /// Gets a value indicating whether flag indicating if logging in with the external provider should auto-link/create a local user /// public bool AutoLinkExternalAccount { get; } + /// + /// By default this is true which allows the user to manually link and unlink the external provider, if set to false the back office user + /// will not see and cannot perform manual linking or unlinking of the external provider. + /// + public bool AllowManualLinking { get; protected set; } + /// /// The default user groups to assign to the created local user linked /// @@ -64,7 +66,7 @@ namespace Umbraco.Cms.Web.BackOffice.Security /// /// The default Culture to use for auto-linking users /// - // TODO: Should we use IDefaultCultureAccessor here intead? + // TODO: Should we use IDefaultCultureAccessor here instead? public string GetUserAutoLinkCulture(GlobalSettings globalSettings) => _defaultCulture ?? globalSettings.DefaultUILanguage; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs index 3dd303d8f7..a41f396faf 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs @@ -282,7 +282,12 @@ namespace Umbraco.Cms.Web.BackOffice.Trees if (_emailSender.CanSendRequiredEmail()) { - AddActionNode(item, menu, true, opensDialog: true); + menu.Items.Add(new MenuItem("notify", LocalizedTextService) + { + Icon = "megaphone", + SeparatorBefore = true, + OpensDialog = true + }); } if((item is DocumentEntitySlim documentEntity && documentEntity.IsContainer) == false) diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs index ecc5b78a51..27eb5b7f6e 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs @@ -118,7 +118,7 @@ namespace Umbraco.Cms.Web.BackOffice.Trees // root actions menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(new MenuItem("importDocumentType", LocalizedTextService) + menu.Items.Add(new MenuItem("importdocumenttype", LocalizedTextService) { Icon = "page-up", SeparatorBefore = true, diff --git a/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs b/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs index d8d8afe13a..08f6d7b400 100644 --- a/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs +++ b/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs @@ -48,6 +48,12 @@ namespace Umbraco.Cms.Web.BackOffice.Trees var attribute = controllerType.GetCustomAttribute(false); if (attribute == null) return; + + bool isCoreTree = controllerType.HasCustomAttribute(false); + + // Use section as tree group if core tree, so it isn't grouped by empty key and thus end up in "Third Party" tree group if adding custom tree nodes in other groups, e.g. "Settings" tree group. + attribute.TreeGroup = attribute.TreeGroup ?? (isCoreTree ? attribute.SectionAlias : attribute.TreeGroup); + var tree = new Tree(attribute.SortOrder, attribute.SectionAlias, attribute.TreeGroup, attribute.TreeAlias, attribute.TreeTitle, attribute.TreeUse, controllerType, attribute.IsSingleNodeTree); _trees.Add(tree); } diff --git a/src/Umbraco.Web.Common/Controllers/RenderController.cs b/src/Umbraco.Web.Common/Controllers/RenderController.cs index 84354e4988..4d74dd1767 100644 --- a/src/Umbraco.Web.Common/Controllers/RenderController.cs +++ b/src/Umbraco.Web.Common/Controllers/RenderController.cs @@ -53,6 +53,26 @@ namespace Umbraco.Cms.Web.Common.Controllers /// public virtual IActionResult Index() => CurrentTemplate(new ContentModel(CurrentPage)); + /// + /// Gets an action result based on the template name found in the route values and a model. + /// + /// The type of the model. + /// The model. + /// The action result. + /// + /// If the template found in the route values doesn't physically exist, Umbraco not found result is returned. + /// + protected override IActionResult CurrentTemplate(T model) + { + if (EnsurePhsyicalViewExists(UmbracoRouteValues.TemplateName) == false) + { + // no physical template file was found + return new PublishedContentNotFoundResult(UmbracoContext); + } + + return View(UmbracoRouteValues.TemplateName, model); + } + /// /// Before the controller executes we will handle redirects and not founds /// @@ -123,6 +143,6 @@ namespace Umbraco.Cms.Web.Common.Controllers { return new PublishedContentNotFoundResult(UmbracoContext); } - } + } } } diff --git a/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs b/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs index 0e6b6d0d0c..f9840df370 100644 --- a/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs +++ b/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs @@ -74,7 +74,7 @@ namespace Umbraco.Cms.Web.Common.Controllers /// The model. /// The action result. /// If the template found in the route values doesn't physically exist and exception is thrown - protected IActionResult CurrentTemplate(T model) + protected virtual IActionResult CurrentTemplate(T model) { if (EnsurePhsyicalViewExists(UmbracoRouteValues.TemplateName) == false) { @@ -90,6 +90,13 @@ namespace Umbraco.Cms.Web.Common.Controllers /// The view name. protected bool EnsurePhsyicalViewExists(string template) { + if (string.IsNullOrWhiteSpace(template)) + { + string docTypeAlias = UmbracoRouteValues.PublishedRequest.PublishedContent.ContentType.Alias; + _logger.LogWarning("No physical template file was found for document type with alias {Alias}", docTypeAlias); + return false; + } + ViewEngineResult result = _compositeViewEngine.FindView(ControllerContext, template, false); if (result.View != null) { @@ -99,6 +106,5 @@ namespace Umbraco.Cms.Web.Common.Controllers _logger.LogWarning("No physical template file was found for template {Template}", template); return false; } - } } diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs index c6856f8f19..98391d7590 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs @@ -1,7 +1,18 @@ +using System; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Security; using Umbraco.Cms.Web.Common.Security; namespace Umbraco.Extensions @@ -34,14 +45,26 @@ namespace Umbraco.Extensions services.AddIdentity() .AddDefaultTokenProviders() - .AddUserStore() + .AddUserStore, MemberUserStore>(factory => new MemberUserStore( + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService() + )) .AddRoleStore() .AddRoleManager() .AddMemberManager() .AddSignInManager() + .AddSignInManager() .AddErrorDescriber() .AddUserConfirmation>(); + + builder.AddNotificationHandler(); + builder.AddNotificationAsyncHandler(); services.ConfigureOptions(); services.AddScoped(x => (IMemberUserStore)x.GetRequiredService>()); @@ -50,6 +73,8 @@ namespace Umbraco.Extensions services.ConfigureOptions(); services.ConfigureOptions(); + services.AddUnique(); + return builder; } } diff --git a/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs b/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs index 77b9f6c8dd..9b80f3e82a 100644 --- a/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs @@ -1,7 +1,9 @@ +using System; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Infrastructure.Security; namespace Umbraco.Extensions { @@ -50,5 +52,22 @@ namespace Umbraco.Extensions identityBuilder.Services.AddScoped(typeof(TInterface), typeof(TSignInManager)); return identityBuilder; } + + + public static IdentityBuilder AddUserStore(this IdentityBuilder identityBuilder, Func implementationFactory) + where TStore : class, TInterface + { + identityBuilder.Services.AddScoped(typeof(TInterface), implementationFactory); + return identityBuilder; + } + + public static MemberIdentityBuilder AddTwoFactorProvider(this MemberIdentityBuilder identityBuilder, string providerName) where T : class, ITwoFactorProvider + { + identityBuilder.Services.AddSingleton(); + identityBuilder.Services.AddSingleton(); + identityBuilder.AddTokenProvider>(providerName); + + return identityBuilder; + } } } diff --git a/src/Umbraco.Web.Common/Extensions/UmbracoApplicationBuilder.Identity.cs b/src/Umbraco.Web.Common/Extensions/UmbracoApplicationBuilder.Identity.cs index 64fde06ac8..e7c0246f40 100644 --- a/src/Umbraco.Web.Common/Extensions/UmbracoApplicationBuilder.Identity.cs +++ b/src/Umbraco.Web.Common/Extensions/UmbracoApplicationBuilder.Identity.cs @@ -20,5 +20,33 @@ namespace Umbraco.Extensions builder.Services.Replace(ServiceDescriptor.Scoped(userManagerType, customType)); return builder; } + + public static IUmbracoBuilder SetBackOfficeUserStore(this IUmbracoBuilder builder) + where TUserStore : BackOfficeUserStore + { + Type customType = typeof(TUserStore); + builder.Services.Replace(ServiceDescriptor.Scoped(typeof(IUserStore<>).MakeGenericType(typeof(BackOfficeIdentityUser)), customType)); + return builder; + } + + public static IUmbracoBuilder SetMemberManager(this IUmbracoBuilder builder) + where TUserManager : UserManager, IMemberManager + { + + Type customType = typeof(TUserManager); + Type userManagerType = typeof(UserManager); + builder.Services.Replace(ServiceDescriptor.Scoped(typeof(IMemberManager), customType)); + builder.Services.AddScoped(customType, services => services.GetRequiredService(userManagerType)); + builder.Services.Replace(ServiceDescriptor.Scoped(userManagerType, customType)); + return builder; + } + + public static IUmbracoBuilder SetMemberUserStore(this IUmbracoBuilder builder) + where TUserStore : MemberUserStore + { + Type customType = typeof(TUserStore); + builder.Services.Replace(ServiceDescriptor.Scoped(typeof(IUserStore<>).MakeGenericType(typeof(MemberIdentityUser)), customType)); + return builder; + } } } diff --git a/src/Umbraco.Web.Common/Extensions/ViewDataExtensions.cs b/src/Umbraco.Web.Common/Extensions/ViewDataExtensions.cs index 36adacc2d2..8e62ca09cf 100644 --- a/src/Umbraco.Web.Common/Extensions/ViewDataExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/ViewDataExtensions.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Semver; using Umbraco.Cms.Core.Serialization; @@ -16,6 +18,7 @@ namespace Umbraco.Extensions public const string TokenUmbracoVersion = "UmbracoVersion"; public const string TokenExternalSignInError = "ExternalSignInError"; public const string TokenPasswordResetCode = "PasswordResetCode"; + public const string TokenTwoFactorRequired = "TwoFactorRequired"; public static bool FromTempData(this ViewDataDictionary viewData, ITempDataDictionary tempData, string token) { @@ -135,5 +138,16 @@ namespace Umbraco.Extensions { viewData[TokenPasswordResetCode] = value; } + + public static void SetTwoFactorProviderNames(this ViewDataDictionary viewData, IEnumerable providerNames) + { + viewData[TokenTwoFactorRequired] = providerNames; + } + + public static bool TryGetTwoFactorProviderNames(this ViewDataDictionary viewData, out IEnumerable providerNames) + { + providerNames = viewData[TokenTwoFactorRequired] as IEnumerable; + return providerNames is not null; + } } } diff --git a/src/Umbraco.Web.Common/Security/IMemberExternalLoginProviders.cs b/src/Umbraco.Web.Common/Security/IMemberExternalLoginProviders.cs new file mode 100644 index 0000000000..b3d6813c2f --- /dev/null +++ b/src/Umbraco.Web.Common/Security/IMemberExternalLoginProviders.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Umbraco.Cms.Web.Common.Security +{ + + /// + /// Service to return instances + /// + public interface IMemberExternalLoginProviders + { + /// + /// Get the for the specified scheme + /// + /// + /// + Task GetAsync(string authenticationType); + + /// + /// Get all registered + /// + /// + Task> GetMemberProvidersAsync(); + } + +} diff --git a/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs b/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs index cc6b0e88b9..4ba5caca9b 100644 --- a/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Umbraco.Cms.Core.Security; + namespace Umbraco.Cms.Web.Common.Security { public interface IMemberSignInManager diff --git a/src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs b/src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs new file mode 100644 index 0000000000..eb6a66a000 --- /dev/null +++ b/src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Web.Common.Security +{ + [Obsolete("This interface will be merged with IMemberSignInManager in Umbraco 10")] + public interface IMemberSignInManagerExternalLogins : IMemberSignInManager + { + AuthenticationProperties ConfigureExternalAuthenticationProperties(string provider, string redirectUrl, string userId = null); + Task GetExternalLoginInfoAsync(string expectedXsrf = null); + Task UpdateExternalAuthenticationTokensAsync(ExternalLoginInfo externalLogin); + Task ExternalLoginSignInAsync(ExternalLoginInfo loginInfo, bool isPersistent, bool bypassTwoFactor = false); + Task GetTwoFactorAuthenticationUserAsync(); + Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient); + } +} diff --git a/src/Umbraco.Web.Common/Security/MemberExternalLoginProvider.cs b/src/Umbraco.Web.Common/Security/MemberExternalLoginProvider.cs new file mode 100644 index 0000000000..9681d47413 --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberExternalLoginProvider.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.Extensions.Options; + +namespace Umbraco.Cms.Web.Common.Security +{ + /// + /// An external login (OAuth) provider for the members + /// + public class MemberExternalLoginProvider : IEquatable + { + public MemberExternalLoginProvider( + string authenticationType, + IOptionsMonitor properties) + { + if (properties is null) + { + throw new ArgumentNullException(nameof(properties)); + } + + AuthenticationType = authenticationType ?? throw new ArgumentNullException(nameof(authenticationType)); + Options = properties.Get(authenticationType); + } + + /// + /// The authentication "Scheme" + /// + public string AuthenticationType { get; } + + public MemberExternalLoginProviderOptions Options { get; } + + public override bool Equals(object obj) => Equals(obj as MemberExternalLoginProvider); + public bool Equals(MemberExternalLoginProvider other) => other != null && AuthenticationType == other.AuthenticationType; + public override int GetHashCode() => HashCode.Combine(AuthenticationType); + } + +} diff --git a/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderOptions.cs b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderOptions.cs new file mode 100644 index 0000000000..ea93a522da --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderOptions.cs @@ -0,0 +1,26 @@ +namespace Umbraco.Cms.Web.Common.Security +{ + /// + /// Options used to configure member external login providers + /// + public class MemberExternalLoginProviderOptions + { + public MemberExternalLoginProviderOptions( + MemberExternalSignInAutoLinkOptions autoLinkOptions = null, + bool autoRedirectLoginToExternalProvider = false, + string customBackOfficeView = null) + { + AutoLinkOptions = autoLinkOptions ?? new MemberExternalSignInAutoLinkOptions(); + } + + public MemberExternalLoginProviderOptions() + { + } + + /// + /// Options used to control how users can be auto-linked/created/updated based on the external login provider + /// + public MemberExternalSignInAutoLinkOptions AutoLinkOptions { get; set; } = new MemberExternalSignInAutoLinkOptions(); + + } +} diff --git a/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderScheme.cs b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderScheme.cs new file mode 100644 index 0000000000..600405b638 --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderScheme.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Authentication; + +namespace Umbraco.Cms.Web.Common.Security +{ + public class MemberExternalLoginProviderScheme + { + public MemberExternalLoginProviderScheme( + MemberExternalLoginProvider externalLoginProvider, + AuthenticationScheme authenticationScheme) + { + ExternalLoginProvider = externalLoginProvider ?? throw new ArgumentNullException(nameof(externalLoginProvider)); + AuthenticationScheme = authenticationScheme ?? throw new ArgumentNullException(nameof(authenticationScheme)); + } + + public MemberExternalLoginProvider ExternalLoginProvider { get; } + public AuthenticationScheme AuthenticationScheme { get; } + } + +} diff --git a/src/Umbraco.Web.Common/Security/MemberExternalLoginProviders.cs b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviders.cs new file mode 100644 index 0000000000..28102c434f --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviders.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Common.Security +{ + + /// + public class MemberExternalLoginProviders : IMemberExternalLoginProviders + { + private readonly Dictionary _externalLogins; + private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; + + public MemberExternalLoginProviders( + IEnumerable externalLogins, + IAuthenticationSchemeProvider authenticationSchemeProvider) + { + _externalLogins = externalLogins.ToDictionary(x => x.AuthenticationType); + _authenticationSchemeProvider = authenticationSchemeProvider; + } + + /// + public async Task GetAsync(string authenticationType) + { + var schemaName = + authenticationType.EnsureStartsWith(Core.Constants.Security.MemberExternalAuthenticationTypePrefix); + + if (!_externalLogins.TryGetValue(schemaName, out MemberExternalLoginProvider provider)) + { + return null; + } + + // get the associated scheme + AuthenticationScheme associatedScheme = await _authenticationSchemeProvider.GetSchemeAsync(provider.AuthenticationType); + + if (associatedScheme == null) + { + throw new InvalidOperationException("No authentication scheme registered for " + provider.AuthenticationType); + } + + return new MemberExternalLoginProviderScheme(provider, associatedScheme); + } + + /// + public async Task> GetMemberProvidersAsync() + { + var providersWithSchemes = new List(); + foreach (MemberExternalLoginProvider login in _externalLogins.Values) + { + // get the associated scheme + AuthenticationScheme associatedScheme = await _authenticationSchemeProvider.GetSchemeAsync(login.AuthenticationType); + + providersWithSchemes.Add(new MemberExternalLoginProviderScheme(login, associatedScheme)); + } + + return providersWithSchemes; + } + + } + +} diff --git a/src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs new file mode 100644 index 0000000000..42dcf6d56f --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.Identity; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Security; +using SecurityConstants = Umbraco.Cms.Core.Constants.Security; + +namespace Umbraco.Cms.Web.Common.Security +{ + /// + /// Options used to configure auto-linking external OAuth providers + /// + public class MemberExternalSignInAutoLinkOptions + { + private readonly string _defaultCulture; + + /// + /// Initializes a new instance of the class. + /// + public MemberExternalSignInAutoLinkOptions( + bool autoLinkExternalAccount = false, + bool defaultIsApproved = true, + string defaultMemberTypeAlias = Core.Constants.Conventions.MemberTypes.DefaultAlias, + string defaultCulture = null, + IEnumerable defaultMemberGroups = null) + { + AutoLinkExternalAccount = autoLinkExternalAccount; + DefaultIsApproved = defaultIsApproved; + DefaultMemberTypeAlias = defaultMemberTypeAlias; + _defaultCulture = defaultCulture; + DefaultMemberGroups = defaultMemberGroups ?? Array.Empty(); + } + + /// + /// A callback executed during account auto-linking and before the user is persisted + /// + [IgnoreDataMember] + public Action OnAutoLinking { get; set; } + + /// + /// A callback executed during every time a user authenticates using an external login. + /// returns a boolean indicating if sign in should continue or not. + /// + [IgnoreDataMember] + public Func OnExternalLogin { get; set; } + + /// + /// Gets a value indicating whether flag indicating if logging in with the external provider should auto-link/create a + /// local user + /// + public bool AutoLinkExternalAccount { get; } + + /// + /// Gets the member type alias that auto linked members are created as + /// + public string DefaultMemberTypeAlias { get; } + + /// + /// Gets the IsApproved value for auto linked members. + /// + public bool DefaultIsApproved { get; } + + /// + /// Gets the default member groups to add the user in. + /// + public IEnumerable DefaultMemberGroups { get; } + + /// + /// The default Culture to use for auto-linking users + /// + // TODO: Should we use IDefaultCultureAccessor here instead? + public string GetUserAutoLinkCulture(GlobalSettings globalSettings) => + _defaultCulture ?? globalSettings.DefaultUILanguage; + } +} diff --git a/src/Umbraco.Web.Common/Security/MemberManager.cs b/src/Umbraco.Web.Common/Security/MemberManager.cs index 52bfd6a58b..08fd280b0e 100644 --- a/src/Umbraco.Web.Common/Security/MemberManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberManager.cs @@ -45,6 +45,9 @@ namespace Umbraco.Cms.Web.Common.Security _httpContextAccessor = httpContextAccessor; } + /// + public override bool SupportsUserTwoFactor => true; + /// public async Task IsMemberAuthorizedAsync(IEnumerable allowTypes = null, IEnumerable allowGroups = null, IEnumerable allowMembers = null) { diff --git a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs index 40cc17667d..e8bf1c2eb3 100644 --- a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs @@ -1,12 +1,20 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Security.Principal; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.DependencyInjection; +using Umbraco.Extensions; namespace Umbraco.Cms.Web.Common.Security { @@ -14,8 +22,28 @@ namespace Umbraco.Cms.Web.Common.Security /// /// The sign in manager for members /// - public class MemberSignInManager : UmbracoSignInManager, IMemberSignInManager + public class MemberSignInManager : UmbracoSignInManager, IMemberSignInManagerExternalLogins { + private readonly IMemberExternalLoginProviders _memberExternalLoginProviders; + private readonly IEventAggregator _eventAggregator; + + public MemberSignInManager( + UserManager memberManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + IMemberExternalLoginProviders memberExternalLoginProviders, + IEventAggregator eventAggregator) : + base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + { + _memberExternalLoginProviders = memberExternalLoginProviders; + _eventAggregator = eventAggregator; + } + + [Obsolete("Use ctor with all params")] public MemberSignInManager( UserManager memberManager, IHttpContextAccessor contextAccessor, @@ -24,7 +52,9 @@ namespace Umbraco.Cms.Web.Common.Security ILogger> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation) : - base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + this(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { } // use default scheme for members @@ -40,40 +70,312 @@ namespace Umbraco.Cms.Web.Common.Security protected override string TwoFactorRememberMeAuthenticationType => IdentityConstants.TwoFactorRememberMeScheme; /// - public override Task GetTwoFactorAuthenticationUserAsync() - => throw new NotImplementedException("Two factor is not yet implemented for members"); + public override async Task GetExternalLoginInfoAsync(string expectedXsrf = null) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L422 + // to replace the auth scheme - /// - public override Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) - => throw new NotImplementedException("Two factor is not yet implemented for members"); + var auth = await Context.AuthenticateAsync(ExternalAuthenticationType); + var items = auth?.Properties?.Items; + if (auth?.Principal == null || items == null) + { + Logger.LogDebug(auth?.Failure ?? new NullReferenceException("Context.AuthenticateAsync(ExternalAuthenticationType) is null"), + "The external login authentication failed. No user Principal or authentication items was resolved."); + return null; + } - /// - public override Task IsTwoFactorClientRememberedAsync(MemberIdentityUser user) - => throw new NotImplementedException("Two factor is not yet implemented for members"); + if (!items.ContainsKey(UmbracoSignInMgrLoginProviderKey)) + { + throw new InvalidOperationException($"The external login authenticated successfully but the key {UmbracoSignInMgrLoginProviderKey} was not found in the authentication properties. Ensure you call SignInManager.ConfigureExternalAuthenticationProperties before issuing a ChallengeResult."); + } - /// - public override Task RememberTwoFactorClientAsync(MemberIdentityUser user) - => throw new NotImplementedException("Two factor is not yet implemented for members"); + if (expectedXsrf != null) + { + if (!items.ContainsKey(UmbracoSignInMgrXsrfKey)) + { + return null; + } + var userId = items[UmbracoSignInMgrXsrfKey]; + if (userId != expectedXsrf) + { + return null; + } + } - /// - public override Task ForgetTwoFactorClientAsync() - => throw new NotImplementedException("Two factor is not yet implemented for members"); + var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (providerKey == null || items[UmbracoSignInMgrLoginProviderKey] is not string provider) + { + return null; + } - /// - public override Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) - => throw new NotImplementedException("Two factor is not yet implemented for members"); + var providerDisplayName = (await GetExternalAuthenticationSchemesAsync()).FirstOrDefault(p => p.Name == provider)?.DisplayName ?? provider; + return new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName) + { + AuthenticationTokens = auth.Properties.GetTokens(), + AuthenticationProperties = auth.Properties + }; + } - /// - public override Task GetExternalLoginInfoAsync(string expectedXsrf = null) - => throw new NotImplementedException("External login is not yet implemented for members"); + /// + /// Custom ExternalLoginSignInAsync overload for handling external sign in with auto-linking + /// + public async Task ExternalLoginSignInAsync(ExternalLoginInfo loginInfo, bool isPersistent, bool bypassTwoFactor = false) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // to be able to deal with auto-linking and reduce duplicate lookups + + var autoLinkOptions = (await _memberExternalLoginProviders.GetAsync(loginInfo.LoginProvider))?.ExternalLoginProvider?.Options?.AutoLinkOptions; + var user = await UserManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); + if (user == null) + { + // user doesn't exist so see if we can auto link + return await AutoLinkAndSignInExternalAccount(loginInfo, autoLinkOptions); + } + + if (autoLinkOptions != null && autoLinkOptions.OnExternalLogin != null) + { + var shouldSignIn = autoLinkOptions.OnExternalLogin(user, loginInfo); + if (shouldSignIn == false) + { + LogFailedExternalLogin(loginInfo, user); + return ExternalLoginSignInResult.NotAllowed; + } + } + + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + return await SignInOrTwoFactorAsync(user, isPersistent, loginInfo.LoginProvider, bypassTwoFactor); + } + + + /// + /// Used for auto linking/creating user accounts for external logins + /// + /// + /// + /// + private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo, MemberExternalSignInAutoLinkOptions autoLinkOptions) + { + // If there are no autolink options then the attempt is failed (user does not exist) + if (autoLinkOptions == null || !autoLinkOptions.AutoLinkExternalAccount) + { + return SignInResult.Failed; + } + + var email = loginInfo.Principal.FindFirstValue(ClaimTypes.Email); + + //we are allowing auto-linking/creating of local accounts + if (email.IsNullOrWhiteSpace()) + { + return AutoLinkSignInResult.FailedNoEmail; + } + else + { + //Now we need to perform the auto-link, so first we need to lookup/create a user with the email address + var autoLinkUser = await UserManager.FindByEmailAsync(email); + if (autoLinkUser != null) + { + try + { + //call the callback if one is assigned + autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); + } + catch (Exception ex) + { + Logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); + return AutoLinkSignInResult.FailedException(ex.Message); + } + + var shouldLinkUser = autoLinkOptions.OnExternalLogin == null || autoLinkOptions.OnExternalLogin(autoLinkUser, loginInfo); + if (shouldLinkUser) + { + return await LinkUser(autoLinkUser, loginInfo); + } + else + { + LogFailedExternalLogin(loginInfo, autoLinkUser); + return ExternalLoginSignInResult.NotAllowed; + } + } + else + { + var name = loginInfo.Principal?.Identity?.Name; + if (name.IsNullOrWhiteSpace()) throw new InvalidOperationException("The Name value cannot be null"); + + autoLinkUser = MemberIdentityUser.CreateNew(email, email, autoLinkOptions.DefaultMemberTypeAlias, autoLinkOptions.DefaultIsApproved, name); + + foreach (var userGroup in autoLinkOptions.DefaultMemberGroups) + { + autoLinkUser.AddRole(userGroup); + } + + //call the callback if one is assigned + try + { + autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); + } + catch (Exception ex) + { + Logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); + return AutoLinkSignInResult.FailedException(ex.Message); + } + + var userCreationResult = await UserManager.CreateAsync(autoLinkUser); + + if (!userCreationResult.Succeeded) + { + return AutoLinkSignInResult.FailedCreatingUser(userCreationResult.Errors.Select(x => x.Description).ToList()); + } + else + { + var shouldLinkUser = autoLinkOptions.OnExternalLogin == null || autoLinkOptions.OnExternalLogin(autoLinkUser, loginInfo); + if (shouldLinkUser) + { + return await LinkUser(autoLinkUser, loginInfo); + } + else + { + LogFailedExternalLogin(loginInfo, autoLinkUser); + return ExternalLoginSignInResult.NotAllowed; + } + } + } + } + } + + // TODO in v10 we can share this with backoffice by moving the backoffice into common. + public class ExternalLoginSignInResult : SignInResult + { + public static ExternalLoginSignInResult NotAllowed { get; } = new ExternalLoginSignInResult() + { + Succeeded = false + }; + } + // TODO in v10 we can share this with backoffice by moving the backoffice into common. + public class AutoLinkSignInResult : SignInResult + { + public static AutoLinkSignInResult FailedNotLinked { get; } = new AutoLinkSignInResult() + { + Succeeded = false + }; + + public static AutoLinkSignInResult FailedNoEmail { get; } = new AutoLinkSignInResult() + { + Succeeded = false + }; + + public static AutoLinkSignInResult FailedException(string error) => new AutoLinkSignInResult(new[] { error }) + { + Succeeded = false + }; + + public static AutoLinkSignInResult FailedCreatingUser(IReadOnlyCollection errors) => new AutoLinkSignInResult(errors) + { + Succeeded = false + }; + + public static AutoLinkSignInResult FailedLinkingUser(IReadOnlyCollection errors) => new AutoLinkSignInResult(errors) + { + Succeeded = false + }; + + public AutoLinkSignInResult(IReadOnlyCollection errors) + { + Errors = errors ?? throw new ArgumentNullException(nameof(errors)); + } + + public AutoLinkSignInResult() + { + } + + public IReadOnlyCollection Errors { get; } = Array.Empty(); + } /// public override AuthenticationProperties ConfigureExternalAuthenticationProperties(string provider, string redirectUrl, string userId = null) - => throw new NotImplementedException("External login is not yet implemented for members"); + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // to be able to use our own XsrfKey/LoginProviderKey because the default is private :/ + + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + properties.Items[UmbracoSignInMgrLoginProviderKey] = provider; + if (userId != null) + { + properties.Items[UmbracoSignInMgrXsrfKey] = userId; + } + return properties; + } /// public override Task> GetExternalAuthenticationSchemesAsync() - => throw new NotImplementedException("External login is not yet implemented for members"); + { + // That can be done by either checking the scheme (maybe) or comparing it to what we have registered in the collection of BackOfficeExternalLoginProvider + return base.GetExternalAuthenticationSchemesAsync(); + } + private async Task LinkUser(MemberIdentityUser autoLinkUser, ExternalLoginInfo loginInfo) + { + var existingLogins = await UserManager.GetLoginsAsync(autoLinkUser); + var exists = existingLogins.FirstOrDefault(x => x.LoginProvider == loginInfo.LoginProvider && x.ProviderKey == loginInfo.ProviderKey); + + // if it already exists (perhaps it was added in the AutoLink callbak) then we just continue + if (exists != null) + { + //sign in + return await SignInOrTwoFactorAsync(autoLinkUser, isPersistent: false, loginInfo.LoginProvider); + } + + var linkResult = await UserManager.AddLoginAsync(autoLinkUser, loginInfo); + if (linkResult.Succeeded) + { + //we're good! sign in + return await SignInOrTwoFactorAsync(autoLinkUser, isPersistent: false, loginInfo.LoginProvider); + } + + //If this fails, we should really delete the user since it will be in an inconsistent state! + var deleteResult = await UserManager.DeleteAsync(autoLinkUser); + if (deleteResult.Succeeded) + { + var errors = linkResult.Errors.Select(x => x.Description).ToList(); + return AutoLinkSignInResult.FailedLinkingUser(errors); + } + else + { + //DOH! ... this isn't good, combine all errors to be shown + var errors = linkResult.Errors.Concat(deleteResult.Errors).Select(x => x.Description).ToList(); + return AutoLinkSignInResult.FailedLinkingUser(errors); + } + } + + private void LogFailedExternalLogin(ExternalLoginInfo loginInfo, MemberIdentityUser user) => + Logger.LogWarning("The AutoLinkOptions of the external authentication provider '{LoginProvider}' have refused the login based on the OnExternalLogin method. Affected user id: '{UserId}'", loginInfo.LoginProvider, user.Id); + + protected override async Task SignInOrTwoFactorAsync(MemberIdentityUser user, bool isPersistent, + string loginProvider = null, bool bypassTwoFactor = false) + { + var result = await base.SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); + + if (result.RequiresTwoFactor) + { + NotifyRequiresTwoFactor(user); + } + + return result; + } + + protected void NotifyRequiresTwoFactor(MemberIdentityUser user) => Notify(user, + (currentUser) => new MemberTwoFactorRequestedNotification(currentUser.Key) + ); + + private T Notify(MemberIdentityUser currentUser, Func createNotification) where T : INotification + { + + var notification = createNotification(currentUser); + _eventAggregator.Publish(notification); + return notification; + } } } diff --git a/src/Umbraco.Web.Common/Security/TwoFactorValidationProvider.cs b/src/Umbraco.Web.Common/Security/TwoFactorValidationProvider.cs new file mode 100644 index 0000000000..32b3226440 --- /dev/null +++ b/src/Umbraco.Web.Common/Security/TwoFactorValidationProvider.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +using Microsoft.Extensions.Logging; + +namespace Umbraco.Cms.Infrastructure.Security +{ + public class TwoFactorBackOfficeValidationProvider : TwoFactorValidationProvider + where TTwoFactorSetupGenerator : ITwoFactorProvider + { + protected TwoFactorBackOfficeValidationProvider(IDataProtectionProvider dataProtectionProvider, IOptions options, ILogger> logger, ITwoFactorLoginService twoFactorLoginService, TTwoFactorSetupGenerator generator) : base(dataProtectionProvider, options, logger, twoFactorLoginService, generator) + { + } + + } + + public class TwoFactorMemberValidationProvider : TwoFactorValidationProvider + where TTwoFactorSetupGenerator : ITwoFactorProvider + { + public TwoFactorMemberValidationProvider(IDataProtectionProvider dataProtectionProvider, IOptions options, ILogger> logger, ITwoFactorLoginService twoFactorLoginService, TTwoFactorSetupGenerator generator) : base(dataProtectionProvider, options, logger, twoFactorLoginService, generator) + { + } + + } + + public class TwoFactorValidationProvider + : DataProtectorTokenProvider + where TUmbracoIdentityUser : UmbracoIdentityUser + where TTwoFactorSetupGenerator : ITwoFactorProvider + { + private readonly ITwoFactorLoginService _twoFactorLoginService; + private readonly TTwoFactorSetupGenerator _generator; + + protected TwoFactorValidationProvider( + + IDataProtectionProvider dataProtectionProvider, + IOptions options, + ILogger> logger, + ITwoFactorLoginService twoFactorLoginService, + TTwoFactorSetupGenerator generator) + : base(dataProtectionProvider, options, logger) + { + _twoFactorLoginService = twoFactorLoginService; + _generator = generator; + } + + public override Task CanGenerateTwoFactorTokenAsync(UserManager manager, + TUmbracoIdentityUser user) => Task.FromResult(_generator is not null); + + public override async Task ValidateAsync(string purpose, string token, + UserManager manager, TUmbracoIdentityUser user) + { + var secret = + await _twoFactorLoginService.GetSecretForUserAndProviderAsync(GetUserKey(user), _generator.ProviderName); + + if (secret is null) + { + return false; + } + + var validToken = _generator.ValidateTwoFactorPIN(secret, token); + + + return validToken; + } + + protected Guid GetUserKey(TUmbracoIdentityUser user) + { + + switch (user) + { + case MemberIdentityUser memberIdentityUser: + return memberIdentityUser.Key; + case BackOfficeIdentityUser backOfficeIdentityUser: + return backOfficeIdentityUser.Key; + default: + throw new NotSupportedException( + "Current we only support MemberIdentityUser and BackOfficeIdentityUser"); + } + + } + + } +} diff --git a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj index 0c01f07da8..b023b5ecdc 100644 --- a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj +++ b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/Umbraco.Web.UI.Client/gulp/tasks/dependencies.js b/src/Umbraco.Web.UI.Client/gulp/tasks/dependencies.js index e8a40e9d70..55390bb520 100644 --- a/src/Umbraco.Web.UI.Client/gulp/tasks/dependencies.js +++ b/src/Umbraco.Web.UI.Client/gulp/tasks/dependencies.js @@ -134,7 +134,7 @@ function dependencies() { "./node_modules/angular-messages/angular-messages.min.js.map" ], "base": "./node_modules/angular-messages" - }, + }, { "name": "angular-mocks", "src": ["./node_modules/angular-mocks/angular-mocks.js"], @@ -285,11 +285,11 @@ function dependencies() { // add streams for node modules nodeModules.forEach(module => { var task = gulp.src(module.src, { base: module.base, allowEmpty: true }); - + _.forEach(config.roots, function(root){ task = task.pipe(gulp.dest(root + config.targets.lib + "/" + module.name)) }); - + stream.add(task); }); @@ -299,12 +299,12 @@ function dependencies() { _.forEach(config.roots, function(root){ libTask = libTask.pipe(gulp.dest(root + config.targets.lib)) }); - + stream.add(libTask); //Copies all static assets into /root / assets folder //css, fonts and image files - + var assetsTask = gulp.src(config.sources.globs.assets, { allowEmpty: true }); assetsTask = assetsTask.pipe(imagemin([ imagemin.gifsicle({interlaced: true}), @@ -321,8 +321,8 @@ function dependencies() { _.forEach(config.roots, function(root){ assetsTask = assetsTask.pipe(gulp.dest(root + config.targets.assets)); }); - - + + stream.add(assetsTask); // Copies all the less files related to the preview into their folder @@ -342,13 +342,13 @@ function dependencies() { configTask = configTask.pipe(gulp.dest(root + config.targets.views + "/propertyeditors/grid/config")); }); stream.add(configTask); - + var dashboardTask = gulp.src("src/views/dashboard/default/*.jpg", { allowEmpty: true }); _.forEach(config.roots, function(root){ dashboardTask = dashboardTask .pipe(gulp.dest(root + config.targets.views + "/dashboard/default")); }); stream.add(dashboardTask); - + return stream; }; diff --git a/src/Umbraco.Web.UI.Client/gulpfile.js b/src/Umbraco.Web.UI.Client/gulpfile.js index ffc5f061cc..81fa1d79cd 100644 --- a/src/Umbraco.Web.UI.Client/gulpfile.js +++ b/src/Umbraco.Web.UI.Client/gulpfile.js @@ -31,6 +31,8 @@ const coreBuild = parallel(dependencies, js, less, views); // *********************************************************** exports.build = series(coreBuild, testUnit); +exports.buildDev = series(setDevelopmentMode, coreBuild); + exports.coreBuild = coreBuild; exports.dev = series(setDevelopmentMode, coreBuild, runUnitTestServer, watchTask); exports.watch = series(watchTask); diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index a9b706a677..0145421fa9 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -8256,12 +8256,6 @@ "object-visit": "^1.0.0" } }, - "marked": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", - "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==", - "dev": true - }, "matchdep": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 43d7a3cecd..ef8135487a 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -7,6 +7,7 @@ "e2e": "gulp testE2e", "build": "gulp build", "build:skip-tests": "gulp coreBuild", + "build:dev": "gulp buildDev", "dev": "gulp dev", "fastdev": "gulp fastdev", "watch": "gulp watch" @@ -87,7 +88,6 @@ "karma-spec-reporter": "0.0.32", "less": "3.10.3", "lodash": "4.17.21", - "marked": "^0.7.0", "merge-stream": "2.0.0", "run-sequence": "2.2.1" } diff --git a/src/Umbraco.Web.UI.Client/src/assets/fonts/web.config b/src/Umbraco.Web.UI.Client/src/assets/fonts/web.config deleted file mode 100644 index 42051b6de2..0000000000 --- a/src/Umbraco.Web.UI.Client/src/assets/fonts/web.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbpropertyfileupload.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbpropertyfileupload.directive.js index 5492fee1a0..6656f370d0 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbpropertyfileupload.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbpropertyfileupload.directive.js @@ -117,12 +117,11 @@ vm.files = _.map(files, function (file) { var f = { fileName: file, + fileSrc: file, isImage: mediaHelper.detectIfImageByExtension(file), extension: getExtension(file) }; - f.fileSrc = getThumbnail(f); - return f; }); @@ -190,21 +189,6 @@ } } - function getThumbnail(file) { - - if (file.extension === 'svg') { - return file.fileName; - } - - if (!file.isImage) { - return null; - } - - var thumbnailUrl = mediaHelper.getThumbnailFromPath(file.fileName); - - return thumbnailUrl; - } - function getExtension(fileName) { var extension = fileName.substring(fileName.lastIndexOf(".") + 1, fileName.length); return extension.toLowerCase(); @@ -238,7 +222,8 @@ isImage: isImage, extension: extension, fileName: files[i].name, - isClientSide: true + isClientSide: true, + fileData: files[i] }; // Save the file object to the files collection @@ -247,6 +232,7 @@ //special check for a comma in the name newVal += files[i].name.split(',').join('-') + ","; + // TODO: I would love to remove this part. But I'm affright it would be breaking if removed. Its not used by File upload anymore as each preview handles the client-side data on their own. if (isImage || extension === "svg") { var deferred = $q.defer(); diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js index d94bb4e6be..ff05656a4e 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js @@ -152,12 +152,12 @@ function entityResource($q, $http, umbRequestHelper) { $http.post( umbRequestHelper.getApiUrl( "entityApiBaseUrl", - "GetUrlsByUdis", + "GetUrlsByIds", query), { - udis: udis + ids: ids }), - 'Failed to retrieve url map for udis ' + udis); + 'Failed to retrieve url map for ids ' + ids); }, getUrlByUdi: function (udi, culture) { diff --git a/src/Umbraco.Web.UI.Client/src/common/services/mediapreview.service.js b/src/Umbraco.Web.UI.Client/src/common/services/mediapreview.service.js new file mode 100644 index 0000000000..b922e07c9c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/services/mediapreview.service.js @@ -0,0 +1,94 @@ +/** +* @ngdoc service +* @name umbraco.services.mediaPreview +* @description A service providing views used for dealing with previewing files. +* +* ##usage +* The service allows for registering and retrieving the view for one or more file extensions. +* +* You can register your own custom view in this way: +* +*
+*    angular.module('umbraco').run(['mediaPreview', function (mediaPreview) {
+*        mediaPreview.registerPreview(['docx'], "app_plugins/My_PACKAGE/preview.html");
+*    }]);
+* 
+* +* Here is a example of a preview template. (base on the audio-preview). +* +*
+*   
+*    
+* 
+* +* Notice that there often is a need to differentiate based on the file-data origin. In the state of the file still begin located locally its often needed to create an Object-URL for the data to be useable in HTML. As well you might want to provide links for the uploaded file when it is uploaded to the server. See 'vm.clientSide' and 'vm.clientSideData'. +* +**/ +function mediaPreview() { + + const DEFAULT_FILE_PREVIEW = "views/components/media/umbfilepreview/umb-file-preview.html"; + + var _mediaPreviews = []; + + function init(service) { + service.registerPreview(Umbraco.Sys.ServerVariables.umbracoSettings.imageFileTypes.split(","), "views/components/media/umbimagepreview/umb-image-preview.html"); + service.registerPreview(["svg"], "views/components/media/umbimagepreview/umb-image-preview.html"); + service.registerPreview(["mp4", "mov", "webm", "ogv"], "views/components/media/umbvideopreview/umb-video-preview.html"); + service.registerPreview(["mp3", "weba", "oga", "opus"], "views/components/media/umbaudiopreview/umb-audio-preview.html"); + } + + var service = { + + /** + * @ngdoc method + * @name umbraco.services.mediaPreview#getMediaPreview + * @methodOf umbraco.services.mediaPreview + * + * @param {string} fileExtension A string with the file extension, example: "pdf" + * + * @description + * The registered view matching this file extensions will be returned. + * + */ + getMediaPreview: function (fileExtension) { + + fileExtension = fileExtension.toLowerCase(); + + var previewObject = _mediaPreviews.find((preview) => preview.fileExtensions.indexOf(fileExtension) !== -1); + + if(previewObject !== undefined) { + return previewObject.view; + } + + return DEFAULT_FILE_PREVIEW; + }, + + /** + * @ngdoc method + * @name umbraco.services.mediaPreview#registerPreview + * @methodOf umbraco.services.mediaPreview + * + * @param {array} fileExtensions An array of file extensions, example: ["pdf", "jpg"] + * @param {array} view A URL to the view to be used for these file extensions. + * + * @description + * The registered view will be used when file extensions match the given file. + * + */ + registerPreview: function (fileExtensions, view) { + _mediaPreviews.push({ + fileExtensions: fileExtensions.map(e => e.toLowerCase()), + view: view + }) + } + + }; + + init(service); + + return service; +} angular.module('umbraco.services').factory('mediaPreview', mediaPreview); diff --git a/src/Umbraco.Web.UI.Client/src/less/belle.less b/src/Umbraco.Web.UI.Client/src/less/belle.less index ba4df33aa0..fa0543aeaf 100644 --- a/src/Umbraco.Web.UI.Client/src/less/belle.less +++ b/src/Umbraco.Web.UI.Client/src/less/belle.less @@ -202,6 +202,11 @@ @import "components/contextdialogs/umb-dialog-datatype-delete.less"; @import "components/umbemailmarketing.less"; +@import "../views/components/media/umbmediapreview/umb-media-preview.less"; +@import "../views/components/media/umbaudiopreview/umb-audio-preview.less"; +@import "../views/components/media/umbfilepreview/umb-file-preview.less"; +@import "../views/components/media/umbimagepreview/umb-image-preview.less"; +@import "../views/components/media/umbvideopreview/umb-video-preview.less"; // Editors @import "../views/common/infiniteeditors/rollback/rollback.less"; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-property-file-upload.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-property-file-upload.less index 75d171dd87..5856b0bd04 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-property-file-upload.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-property-file-upload.less @@ -38,4 +38,8 @@ border-color: @gray-1; } } + + .umb-property-file-upload--actions { + margin-top: 10px; + } } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-tags-editor.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-tags-editor.less index 213807e685..2d41cbe6f2 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-tags-editor.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-tags-editor.less @@ -1,4 +1,4 @@ -.umb-tags-editor { +.umb-tags-editor { border: @inputBorder solid 1px; padding: 5px; min-height: 54px; @@ -14,24 +14,26 @@ position: relative; user-select: all; + > .btn-icon { + color: @white; + padding: 0; + position: relative; + cursor: pointer; + padding-left: 2px; + font-size: 15px; + right: -5px; + bottom: -1px; + user-select: none; + } + .umb_confirm-action { - > .btn-icon { - color: @white; - padding: 0; - position: relative; - cursor: pointer; - padding-left: 2px; - font-size: 15px; - right: -5px; - bottom: -1px; - user-select: none; - } - - .umb_confirm-action__overlay.-left { - top: 8px; - left: auto; - right: 15px; + &__overlay { + &.-left { + top: 8px; + left: auto; + right: 15px; + } } } } diff --git a/src/Umbraco.Web.UI.Client/src/less/pages/login.less b/src/Umbraco.Web.UI.Client/src/less/pages/login.less index cf49af526b..2763a879ea 100644 --- a/src/Umbraco.Web.UI.Client/src/less/pages/login.less +++ b/src/Umbraco.Web.UI.Client/src/less/pages/login.less @@ -30,11 +30,14 @@ position: absolute; top: 22px; left: 25px; - width: 30px; height: 30px; z-index: 1; } +.login-overlay__logo > img { + max-height:100%; +} + .login-overlay .umb-modalcolumn { background: none; border: none; diff --git a/src/Umbraco.Web.UI.Client/src/less/property-editors.less b/src/Umbraco.Web.UI.Client/src/less/property-editors.less index 2805e7f79b..c3ad08b8f8 100644 --- a/src/Umbraco.Web.UI.Client/src/less/property-editors.less +++ b/src/Umbraco.Web.UI.Client/src/less/property-editors.less @@ -828,11 +828,15 @@ .umb-fileupload { display: flex; flex-direction: column; + padding: 20px; + border: 1px solid @inputBorder; + box-sizing: border-box; + width: 100%; + .umb-property-editor--limit-width(); } .umb-fileupload .preview { border-radius: 5px; - border: 1px solid @gray-6; padding: 3px; background: @gray-9; float: left; diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js index 6c8a038536..eea8e87034 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js @@ -38,6 +38,8 @@ angular.module("umbraco") entityResource.getById(vm.mediaEntry.mediaKey, "Media").then(function (mediaEntity) { vm.media = mediaEntity; vm.imageSrc = mediaHelper.resolveFileFromEntity(mediaEntity, true); + vm.fileSrc = mediaHelper.resolveFileFromEntity(mediaEntity, false); + vm.fileExtension = mediaHelper.getFileExtension(vm.fileSrc); vm.loading = false; vm.hasDimensions = false; vm.isCroppable = false; diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html index 938d719431..a56e3aeed6 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html @@ -1,127 +1,148 @@ -
+
+ + + + - +
+
+ + This item is in the Recycle Bin +
- +
+
+ - - - -
- -
- This item is in the Recycle Bin -
- -
-
- - - - -
- -
- -
- - - - - -
- -
- - - - - - - -
- - - -
-
- -
-
+ +
+
+
+ + +
- +
+ + - + + - +
+ + + +
+
+
+
+
- - + + - - + + + - - - -
- + + + + + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.less b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.less index 139d7bef4a..982ef7bc63 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.less +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.less @@ -110,11 +110,23 @@ } .umb-media-entry-editor__imageholder { - display: flex; - align-items: center; - justify-content: center; + position: relative; height: calc(100% - 50px); + + display: block; +} + +.umb-media-entry-editor__previewholder { + + position: relative; + height: calc(100% - 50px); + + display: flex; + justify-content: center; + align-items: center; + + overflow-y: auto; } .umb-media-entry-editor__imageholder-actions { diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html index fa85785868..24acef995e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html @@ -69,7 +69,7 @@ ng-click="unlink($event, login.authType, login.linkedProviderKey)" ng-if="login.linkedProviderKey != undefined" value="{{login.authType}}"> - + Un-link your {{login.caption}} account diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.html b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.html new file mode 100644 index 0000000000..4b7bd1c2a6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.html @@ -0,0 +1,8 @@ +
+ + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.less b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.less new file mode 100644 index 0000000000..5d69fa07f9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.less @@ -0,0 +1,18 @@ +.umb-audio-preview { + display: flex; + justify-content: center; + align-items: center; + audio { + max-width: 100%; + } + audio::-webkit-media-controls-panel { + background-color: white; + } + audio::-webkit-media-controls { + padding: 6px; + } + audio::-webkit-media-controls-enclosure { + &:extend(.shadow-depth-1); + border-radius: 6px; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umbaudiopreview.controller.js b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umbaudiopreview.controller.js new file mode 100644 index 0000000000..985c8540a3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umbaudiopreview.controller.js @@ -0,0 +1,11 @@ +angular.module("umbraco") + .controller("umbAudioPreviewController", + function () { + + var vm = this; + + vm.getClientSideUrl = function(source) { + return URL.createObjectURL(source); + } + + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.html b/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.html new file mode 100644 index 0000000000..2c72e27c82 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.html @@ -0,0 +1,18 @@ +
+ + +
{{vm.name}}
+
+
+ +
{{vm.name}}
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.less b/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.less new file mode 100644 index 0000000000..427ac38244 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.less @@ -0,0 +1,13 @@ +.umb-file-preview { + display: flex; + justify-content: center; + align-items: center; + + .umb-file-preview--file { + display: block; + box-sizing: border-box; + text-align: center; + max-width: 320px; + padding: 10px; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.html b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.html new file mode 100644 index 0000000000..989f8ef093 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.html @@ -0,0 +1,6 @@ +
+ {{vm.name}} + + {{vm.name}} + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.less b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.less new file mode 100644 index 0000000000..13f934c251 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.less @@ -0,0 +1,9 @@ +.umb-image-preview { + display: flex; + justify-content: center; + align-items: center; + + img { + width: 100%; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umbimagepreview.controller.js b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umbimagepreview.controller.js new file mode 100644 index 0000000000..36eb3958e2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umbimagepreview.controller.js @@ -0,0 +1,18 @@ + + + + +angular.module("umbraco") + .controller("umbImagePreviewController", + function (mediaHelper) { + + var vm = this; + + vm.getThumbnail = function(source) { + return mediaHelper.getThumbnailFromPath(source) || source; + } + vm.getClientSideUrl = function(sourceData) { + return URL.createObjectURL(sourceData); + } + + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umb-media-preview.less b/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umb-media-preview.less new file mode 100644 index 0000000000..78c41eeab6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umb-media-preview.less @@ -0,0 +1,14 @@ +umb-media-preview { + position: relative; +} +.umb-media-preview { + position: relative; + width: 100%; + height: 100%; + + min-height: 240px; + + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umbmediapreview.component.js b/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umbmediapreview.component.js new file mode 100644 index 0000000000..b7b5536b0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umbmediapreview.component.js @@ -0,0 +1,38 @@ +(function () { + "use strict"; + + angular + .module("umbraco") + .component("umbMediaPreview", { + template: "
", + controller: UmbMediaPreviewController, + controllerAs: "vm", + bindings: { + extension: "<", + source: "<", + name: "<", + clientSide: " { + vm.loading = true; + }) + $scope.$on("mediaPreviewLoadingComplete", () => { + vm.loading = false; + }) + + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.html b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.html new file mode 100644 index 0000000000..26003ab4c1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.html @@ -0,0 +1,8 @@ +
+ + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.less b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.less new file mode 100644 index 0000000000..3dd4e2f589 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.less @@ -0,0 +1,10 @@ +.umb-video-preview { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + video { + max-width: 100%; + max-height: 100%; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umbvideopreview.controller.js b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umbvideopreview.controller.js new file mode 100644 index 0000000000..9c8b32d8b7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umbvideopreview.controller.js @@ -0,0 +1,15 @@ + + + + +angular.module("umbraco") + .controller("umbVideoPreviewController", + function () { + + var vm = this; + + vm.getClientSideUrl = function(source) { + return URL.createObjectURL(source); + } + + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.html b/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.html index 76e1e99314..e54cc3e898 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.html @@ -30,7 +30,7 @@ diff --git a/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umbMediaCard.component.js b/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umbMediaCard.component.js index 24b20367aa..1014b95227 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umbMediaCard.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umbMediaCard.component.js @@ -74,6 +74,7 @@ vm.media = mediaEntity; checkErrorState(); vm.thumbnail = mediaHelper.resolveFileFromEntity(mediaEntity, true); + vm.fileExtension = mediaHelper.getFileExtension(vm.media.metaData.MediaPath); vm.loading = false; }, function () { diff --git a/src/Umbraco.Web.UI.Client/src/views/components/tags/umb-tags-editor.html b/src/Umbraco.Web.UI.Client/src/views/components/tags/umb-tags-editor.html index e8f77d09a5..7277ff63c2 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/tags/umb-tags-editor.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/tags/umb-tags-editor.html @@ -13,6 +13,7 @@ diff --git a/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-property-file-upload.html b/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-property-file-upload.html index ce5d7292c9..a6df878997 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-property-file-upload.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-property-file-upload.html @@ -1,58 +1,68 @@ 
+ + - - +
+ +

Click to upload

+ +
-
- -

Click to upload

- +
+
+
+
- -
-
- -
- -
-
-
- {{file.fileName}} - - {{file.fileName}} - -
-
-
- -
- - - -
{{file.fileName}}
-
-
- - -
{{file.fileName}}
-
-
-
-
- - - -
- -
-
-
-
+
+ + +
- - +
+
+
+
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/web.config b/src/Umbraco.Web.UI.Client/src/web.config deleted file mode 100644 index 6d14a9bab7..0000000000 --- a/src/Umbraco.Web.UI.Client/src/web.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 6887bd7f63..56511315ac 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -103,8 +103,6 @@ - - diff --git a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml index 09954b3f8d..1b1ebd7284 100644 --- a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml +++ b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml @@ -1,12 +1,14 @@ @inherits Umbraco.Cms.Web.Common.Macros.PartialViewMacroPage -@using Umbraco.Cms.Core -@using Umbraco.Cms.Core.Security +@using Umbraco.Cms.Core.Services +@using Umbraco.Cms.Web.Common.Security @using Umbraco.Cms.Web.Website.Controllers @using Umbraco.Cms.Web.Website.Models @using Umbraco.Extensions @inject MemberModelBuilderFactory memberModelBuilderFactory; - +@inject IMemberExternalLoginProviders memberExternalLoginProviders +@inject IExternalLoginWithKeyService externalLoginWithKeyService @{ + // Build a profile model to edit var profileModel = await memberModelBuilderFactory .CreateProfileModel() @@ -17,6 +19,13 @@ .BuildForCurrentMemberAsync(); var success = TempData["FormSuccess"] != null; + + var loginProviders = await memberExternalLoginProviders.GetMemberProvidersAsync(); + var externalSignInError = ViewData.GetExternalSignInProviderErrors(); + + var currentExternalLogin = profileModel is null + ? new Dictionary() + : externalLoginWithKeyService.GetExternalLogins(profileModel.Key).ToDictionary(x=>x.LoginProvider, x=>x.ProviderKey); } @@ -70,5 +79,50 @@ } + + if (loginProviders.Any()) + { +
+

Link external accounts

+ + if (externalSignInError?.AuthenticationType is null && externalSignInError?.Errors.Any() == true) + { + @Html.DisplayFor(x => externalSignInError.Errors); + } + + @foreach (var login in loginProviders) + { + if (currentExternalLogin.TryGetValue(login.ExternalLoginProvider.AuthenticationType, out var providerKey)) + { + @using (Html.BeginUmbracoForm(nameof(UmbExternalLoginController.Disassociate))) + { + + + + if (externalSignInError?.AuthenticationType == login.ExternalLoginProvider.AuthenticationType) + { + @Html.DisplayFor(x => externalSignInError.Errors); + } + } + } + else + { + @using (Html.BeginUmbracoForm(nameof(UmbExternalLoginController.LinkLogin))) + { + + + if (externalSignInError?.AuthenticationType == login.ExternalLoginProvider.AuthenticationType) + { + @Html.DisplayFor(x => externalSignInError.Errors); + } + } + } + + } + } } } diff --git a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml index d3c389c78d..7ba7f2acca 100644 --- a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml +++ b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml @@ -1,9 +1,12 @@ @inherits Umbraco.Cms.Web.Common.Macros.PartialViewMacroPage -@using Microsoft.AspNetCore.Http.Extensions -@using Umbraco.Cms.Web.Common.Models -@using Umbraco.Cms.Web.Website.Controllers -@using Umbraco.Extensions +@using Umbraco.Cms.Web.Common.Models +@using Umbraco.Cms.Web.Common.Security +@using Umbraco.Cms.Web.Website.Controllers +@using Umbraco.Cms.Core.Services +@using Umbraco.Extensions +@inject IMemberExternalLoginProviders memberExternalLoginProviders +@inject ITwoFactorLoginService twoFactorLoginService @{ var loginModel = new LoginModel(); // You can modify this to redirect to a different URL instead of the current one @@ -14,32 +17,91 @@ + +@if (ViewData.TryGetTwoFactorProviderNames(out var providerNames)) +{ + + foreach (var providerName in providerNames) + { +
+

Two factor with @providerName.

+
+ @using (Html.BeginUmbracoForm(nameof(UmbTwoFactorLoginController.Verify2FACode))) + { + + + + Input security code:
+ +
+
+ } +
+ } + +} +else +{ + + +} diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml b/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml index a90aa33355..fbbf9a495a 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml @@ -19,7 +19,7 @@ Vyprázdnit koš Aktivovat Exportovat typ dokumentu - Importovat typ dokumentu + Importovat typ dokumentu Importovat balíček Editovat na stránce Odhlásit diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml b/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml index 60ff3ffdb1..94fde5ebfe 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml @@ -21,7 +21,7 @@ Gwagu bin ailgylchu Galluogi Allforio Math o Ddogfen - Mewnforio Math o Ddogfen + Mewnforio Math o Ddogfen Mewnforio Pecyn Golygu mewn Cynfas Gadael diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/da.xml b/src/Umbraco.Web.UI/umbraco/config/lang/da.xml index 1f6dcff88f..5b90b7511d 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/da.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/da.xml @@ -21,7 +21,7 @@ Tøm papirkurv Aktivér Eksportér dokumenttype - Importér dokumenttype + Importér dokumenttype Importér pakke Redigér i Canvas Log af diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/de.xml b/src/Umbraco.Web.UI/umbraco/config/lang/de.xml index 0cabf29497..0366fe6853 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/de.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/de.xml @@ -19,7 +19,7 @@ Papierkorb leeren Aktivieren Dokumenttyp exportieren - Dokumenttyp importieren + Dokumenttyp importieren Paket importieren 'Canvas'-Modus starten Abmelden diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml index 47c104b822..4fc52fc0a7 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml @@ -20,7 +20,7 @@ Empty recycle bin Enable Export Document Type - Import Document Type + Import Document Type Import Package Edit in Canvas Exit diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml index e4d2784c8c..2d575ba77f 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml @@ -21,7 +21,7 @@ Empty recycle bin Enable Export Document Type - Import Document Type + Import Document Type Import Package Edit in Canvas Exit diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/es.xml b/src/Umbraco.Web.UI/umbraco/config/lang/es.xml index d99548f5c5..ec8dfbc895 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/es.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/es.xml @@ -18,7 +18,7 @@ Vaciar Papelera Activar Exportar Documento (tipo) - Importar Documento (tipo) + Importar Documento (tipo) Importar Paquete Editar en vivo Cerrar sesión diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml b/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml index 68dc28c99b..4f292c0889 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml @@ -19,7 +19,7 @@ Vider la corbeille Activer Exporter le type de document - Importer un type de document + Importer un type de document Importer un package Editer dans Canvas Déconnexion diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/he.xml b/src/Umbraco.Web.UI/umbraco/config/lang/he.xml index b99890282e..403ea645fc 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/he.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/he.xml @@ -15,7 +15,7 @@ נטרל רוקן סל מיחזור ייצא סוג קובץ - ייבא סוג מסמך + ייבא סוג מסמך ייבא חבילה ערוך במצב "קנבס" יציאה diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/it.xml b/src/Umbraco.Web.UI/umbraco/config/lang/it.xml index 7e20c0b266..357d63e84b 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/it.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/it.xml @@ -21,7 +21,7 @@ Svuota il cestino Abilita Esporta il tipo di documento - Importa il tipo di documento + Importa il tipo di documento Importa il pacchetto Modifica in Area di Lavoro Uscita diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml b/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml index 142f7d055b..1107181cab 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml @@ -16,7 +16,7 @@ 無効 ごみ箱を空にする ドキュメントタイプの書出 - ドキュメントタイプの読込 + ドキュメントタイプの読込 パッケージの読み込み ライブ編集 ログアウト diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml b/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml index c340f3f30a..68cff8a318 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml @@ -15,7 +15,7 @@ 비활성 휴지통 비우기 추출 문서 유형 - 등록 문서 유형 + 등록 문서 유형 패키지 등록 캔버스 내용 편집 종료 diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml b/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml index 44fad5d2ec..341adad4bf 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml @@ -16,7 +16,7 @@ Deaktiver Tøm papirkurv Eksporter dokumenttype - Importer dokumenttype + Importer dokumenttype Importer pakke Rediger i Canvas Logg av diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml index 0c24b119c9..fb6167520b 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml @@ -21,7 +21,7 @@ Prullenbak leegmaken Inschakelen Documenttype exporteren - Documenttype importeren + Documenttype importeren Package importeren Aanpassen in Canvas Afsluiten @@ -770,6 +770,7 @@ Een ogenblik geduld aub... Vorige Eigenschappen + Lees meer Opnieuw opbouwen E-mail om formulier resultaten te ontvangen Prullenbak @@ -1251,6 +1252,10 @@ Echter, Runway biedt een gemakkelijke basis om je snel op weg te helpen. Als je Terugzetten naar Selecteer versie Bekijk + + Versies + Conceptversie + Gepubliceerde versie Bewerk script-bestand @@ -1670,6 +1675,12 @@ Echter, Runway biedt een gemakkelijke basis om je snel op weg te helpen. Als je weggooien? Tabblad toevoegen + Geschiedenis opschonen + Overschrijf de standaard geschiedenis opschonen instellingen. + Bewaar alle versies nieuwer dan dagen + Bewaar de laatste versie per dag voor dagen + Voorkom opschonen + Geschiedenis opschonen is globaal uitgeschakeld. Deze instellingen worden pas van kracht nadat ze zijn ingeschakeld. Taal toevoegen @@ -1794,7 +1805,7 @@ Echter, Runway biedt een gemakkelijke basis om je snel op weg te helpen. Als je Rollen Ledentypes Documenttypes - RelatieTypen + Relatietypes Packages Packages Partial Views diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml b/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml index 712b695f77..b993f19f1b 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml @@ -18,7 +18,7 @@ Opróżnij kosz Aktywuj Eksportuj typ dokumentu - Importuj typ dokumentu + Importuj typ dokumentu Importuj zbiór Edytuj na stronie Wyjście diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml b/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml index eac232e851..c69912099c 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml @@ -15,7 +15,7 @@ Desabilitar Esvaziar Lixeira Exportar Tipo de Documento - Importar Tipo de Documento + Importar Tipo de Documento Importar Pacote Editar na Tela Sair diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml b/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml index 5c18fbc682..24910c2e6f 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml @@ -21,7 +21,7 @@ Включить Экспорт Экспортировать - Импортировать + Импортировать Импортировать пакет Править на месте Выйти diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml b/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml index dda58366d8..0efdedfff3 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml @@ -22,7 +22,7 @@ Avaktivera Töm papperskorgen Exportera dokumenttyp - Importera dokumenttyp + Importera dokumenttyp Importera paket Redigera i Canvas Logga ut diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml b/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml index 66d097b1e9..f54fc31076 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml @@ -20,7 +20,7 @@ Geri dönüşüm kutusunu boşalt Etkinleştir Belge Türünü Dışa Aktar - Belge Türünü İçe Aktar + Belge Türünü İçe Aktar Paketi İçe Aktar Kanvas'ta Düzenle Çıkış diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml b/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml index 8c78154b62..8e7ca24f32 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml @@ -16,7 +16,7 @@ 禁用 清空回收站 导出文档类型 - 导入文档类型 + 导入文档类型 导入扩展包 实时编辑模式 退出 diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml b/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml index 992a7ba55b..0102971ae9 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml @@ -16,7 +16,7 @@ 禁用 清空回收站 匯出文檔類型 - 導入文檔類型 + 導入文檔類型 導入擴展包 即時編輯模式 退出 diff --git a/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs b/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs new file mode 100644 index 0000000000..c43754e170 --- /dev/null +++ b/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs @@ -0,0 +1,284 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Web.Common.ActionsResults; +using Umbraco.Cms.Web.Common.Filters; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; +using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; + +namespace Umbraco.Cms.Web.Website.Controllers +{ + [UmbracoMemberAuthorize] + public class UmbExternalLoginController : SurfaceController + { + private readonly IMemberManager _memberManager; + private readonly ITwoFactorLoginService _twoFactorLoginService; + private readonly ILogger _logger; + private readonly IMemberSignInManagerExternalLogins _memberSignInManager; + + public UmbExternalLoginController( + ILogger logger, + IUmbracoContextAccessor umbracoContextAccessor, + IUmbracoDatabaseFactory databaseFactory, + ServiceContext services, + AppCaches appCaches, + IProfilingLogger profilingLogger, + IPublishedUrlProvider publishedUrlProvider, + IMemberSignInManagerExternalLogins memberSignInManager, + IMemberManager memberManager, + ITwoFactorLoginService twoFactorLoginService) + : base( + umbracoContextAccessor, + databaseFactory, + services, + appCaches, + profilingLogger, + publishedUrlProvider) + { + _logger = logger; + _memberSignInManager = memberSignInManager; + _memberManager = memberManager; + _twoFactorLoginService = twoFactorLoginService; + } + + /// + /// Endpoint used to redirect to a specific login provider. This endpoint is used from the Login Macro snippet. + /// + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public ActionResult ExternalLogin(string provider, string returnUrl = null) + { + if (returnUrl.IsNullOrWhiteSpace()) + { + returnUrl = Request.GetEncodedPathAndQuery(); + } + + var wrappedReturnUrl = + Url.SurfaceAction(nameof(ExternalLoginCallback), this.GetControllerName(), new { returnUrl }); + + AuthenticationProperties properties = + _memberSignInManager.ConfigureExternalAuthenticationProperties(provider, wrappedReturnUrl); + + return Challenge(properties, provider); + } + + /// + /// Endpoint used my the login provider to call back to our solution. + /// + [HttpGet] + [AllowAnonymous] + public async Task ExternalLoginCallback(string returnUrl) + { + var errors = new List(); + + ExternalLoginInfo loginInfo = await _memberSignInManager.GetExternalLoginInfoAsync(); + if (loginInfo is null) + { + errors.Add("Invalid response from the login provider"); + } + else + { + SignInResult result = await _memberSignInManager.ExternalLoginSignInAsync(loginInfo, false); + + if (result == SignInResult.Success) + { + // Update any authentication tokens if succeeded + await _memberSignInManager.UpdateExternalAuthenticationTokensAsync(loginInfo); + + return RedirectToLocal(returnUrl); + } + + if (result == SignInResult.TwoFactorRequired) + { + MemberIdentityUser attemptedUser = + await _memberManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); + if (attemptedUser == null) + { + return new ValidationErrorResult( + $"No local user found for the login provider {loginInfo.LoginProvider} - {loginInfo.ProviderKey}"); + } + + + var providerNames = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(attemptedUser.Key); + ViewData.SetTwoFactorProviderNames(providerNames); + + return CurrentUmbracoPage(); + + } + + if (result == SignInResult.LockedOut) + { + errors.Add( + $"The local member {loginInfo.Principal.Identity.Name} for the external provider {loginInfo.ProviderDisplayName} is locked out."); + } + else if (result == SignInResult.NotAllowed) + { + // This occurs when SignInManager.CanSignInAsync fails which is when RequireConfirmedEmail , RequireConfirmedPhoneNumber or RequireConfirmedAccount fails + // however since we don't enforce those rules (yet) this shouldn't happen. + errors.Add( + $"The member {loginInfo.Principal.Identity.Name} for the external provider {loginInfo.ProviderDisplayName} has not confirmed their details and cannot sign in."); + } + else if (result == SignInResult.Failed) + { + // Failed only occurs when the user does not exist + errors.Add("The requested provider (" + loginInfo.LoginProvider + + ") has not been linked to an account, the provider must be linked before it can be used."); + } + else if (result == MemberSignInManager.ExternalLoginSignInResult.NotAllowed) + { + // This occurs when the external provider has approved the login but custom logic in OnExternalLogin has denined it. + errors.Add( + $"The user {loginInfo.Principal.Identity.Name} for the external provider {loginInfo.ProviderDisplayName} has not been accepted and cannot sign in."); + } + else if (result == MemberSignInManager.AutoLinkSignInResult.FailedNotLinked) + { + errors.Add("The requested provider (" + loginInfo.LoginProvider + + ") has not been linked to an account, the provider must be linked from the back office."); + } + else if (result == MemberSignInManager.AutoLinkSignInResult.FailedNoEmail) + { + errors.Add( + $"The requested provider ({loginInfo.LoginProvider}) has not provided the email claim {ClaimTypes.Email}, the account cannot be linked."); + } + else if (result is MemberSignInManager.AutoLinkSignInResult autoLinkSignInResult && + autoLinkSignInResult.Errors.Count > 0) + { + errors.AddRange(autoLinkSignInResult.Errors); + } + else if (!result.Succeeded) + { + // this shouldn't occur, the above should catch the correct error but we'll be safe just in case + errors.Add($"An unknown error with the requested provider ({loginInfo.LoginProvider}) occurred."); + } + } + + if (errors.Count > 0) + { + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo?.LoginProvider, + errors)); + + return CurrentUmbracoPage(); + } + + return RedirectToLocal(returnUrl); + } + + private void AddModelErrors(IdentityResult result, string prefix = "") + { + foreach (IdentityError error in result.Errors) + { + ModelState.AddModelError(prefix, error.Description); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult LinkLogin(string provider, string returnUrl = null) + { + if (returnUrl.IsNullOrWhiteSpace()) + { + returnUrl = Request.GetEncodedPathAndQuery(); + } + + var wrappedReturnUrl = + Url.SurfaceAction(nameof(ExternalLinkLoginCallback), this.GetControllerName(), new { returnUrl }); + + // Configures the redirect URL and user identifier for the specified external login including xsrf data + AuthenticationProperties properties = + _memberSignInManager.ConfigureExternalAuthenticationProperties(provider, wrappedReturnUrl, + _memberManager.GetUserId(User)); + + return Challenge(properties, provider); + } + + [HttpGet] + public async Task ExternalLinkLoginCallback(string returnUrl) + { + MemberIdentityUser user = await _memberManager.GetUserAsync(User); + string loginProvider = null; + var errors = new List(); + if (user == null) + { + // ... this should really not happen + errors.Add("Local user does not exist"); + } + else + { + ExternalLoginInfo info = + await _memberSignInManager.GetExternalLoginInfoAsync(await _memberManager.GetUserIdAsync(user)); + + if (info == null) + { + //Add error and redirect for it to be displayed + errors.Add( "An error occurred, could not get external login info"); + } + else + { + loginProvider = info.LoginProvider; + IdentityResult addLoginResult = await _memberManager.AddLoginAsync(user, info); + if (addLoginResult.Succeeded) + { + // Update any authentication tokens if succeeded + await _memberSignInManager.UpdateExternalAuthenticationTokensAsync(info); + + return RedirectToLocal(returnUrl); + } + + //Add errors and redirect for it to be displayed + errors.AddRange(addLoginResult.Errors.Select(x => x.Description)); + } + } + + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginProvider, + errors)); + return CurrentUmbracoPage(); + } + + private IActionResult RedirectToLocal(string returnUrl) => + Url.IsLocalUrl(returnUrl) ? Redirect(returnUrl) : RedirectToCurrentUmbracoPage(); + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Disassociate(string provider, string providerKey, string returnUrl = null) + { + if (returnUrl.IsNullOrWhiteSpace()) + { + returnUrl = Request.GetEncodedPathAndQuery(); + } + + MemberIdentityUser user = await _memberManager.FindByIdAsync(User.Identity.GetUserId()); + + IdentityResult result = await _memberManager.RemoveLoginAsync(user, provider, providerKey); + + if (result.Succeeded) + { + await _memberSignInManager.SignInAsync(user, false); + return RedirectToLocal(returnUrl); + } + + AddModelErrors(result); + return CurrentUmbracoPage(); + } + } +} diff --git a/src/Umbraco.Web.Website/Controllers/UmbLoginController.cs b/src/Umbraco.Web.Website/Controllers/UmbLoginController.cs index afeb41a252..9dbcd292e4 100644 --- a/src/Umbraco.Web.Website/Controllers/UmbLoginController.cs +++ b/src/Umbraco.Web.Website/Controllers/UmbLoginController.cs @@ -1,14 +1,20 @@ using System; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Web.Common.ActionsResults; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Cms.Web.Common.Models; using Umbraco.Cms.Web.Common.Security; @@ -20,7 +26,29 @@ namespace Umbraco.Cms.Web.Website.Controllers public class UmbLoginController : SurfaceController { private readonly IMemberSignInManager _signInManager; + private readonly IMemberManager _memberManager; + private readonly ITwoFactorLoginService _twoFactorLoginService; + + [ActivatorUtilitiesConstructor] + public UmbLoginController( + IUmbracoContextAccessor umbracoContextAccessor, + IUmbracoDatabaseFactory databaseFactory, + ServiceContext services, + AppCaches appCaches, + IProfilingLogger profilingLogger, + IPublishedUrlProvider publishedUrlProvider, + IMemberSignInManager signInManager, + IMemberManager memberManager, + ITwoFactorLoginService twoFactorLoginService) + : base(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider) + { + _signInManager = signInManager; + _memberManager = memberManager; + _twoFactorLoginService = twoFactorLoginService; + } + + [Obsolete("Use ctor with all params")] public UmbLoginController( IUmbracoContextAccessor umbracoContextAccessor, IUmbracoDatabaseFactory databaseFactory, @@ -29,9 +57,11 @@ namespace Umbraco.Cms.Web.Website.Controllers IProfilingLogger profilingLogger, IPublishedUrlProvider publishedUrlProvider, IMemberSignInManager signInManager) - : base(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider) + : this(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider, signInManager, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { - _signInManager = signInManager; + } [HttpPost] @@ -74,15 +104,28 @@ namespace Umbraco.Cms.Web.Website.Controllers if (result.RequiresTwoFactor) { - throw new NotImplementedException("Two factor support is not supported for Umbraco members yet"); + MemberIdentityUser attemptedUser = await _memberManager.FindByNameAsync(model.Username); + if (attemptedUser == null) + { + return new ValidationErrorResult( + $"No local member found for username {model.Username}"); + } + + var providerNames = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(attemptedUser.Key); + ViewData.SetTwoFactorProviderNames(providerNames); + } + else if (result.IsLockedOut) + { + ModelState.AddModelError("loginModel", "Member is locked out"); + } + else if (result.IsNotAllowed) + { + ModelState.AddModelError("loginModel", "Member is not allowed"); + } + else + { + ModelState.AddModelError("loginModel", "Invalid username or password"); } - - // TODO: We can check for these and respond differently if we think it's important - // result.IsLockedOut - // result.IsNotAllowed - - // Don't add a field level error, just model level. - ModelState.AddModelError("loginModel", "Invalid username or password"); return CurrentUmbracoPage(); } @@ -97,5 +140,7 @@ namespace Umbraco.Cms.Web.Website.Controllers model.RedirectUrl = redirectUrl.ToString(); } } + + } } diff --git a/src/Umbraco.Web.Website/Controllers/UmbTwoFactorLoginController.cs b/src/Umbraco.Web.Website/Controllers/UmbTwoFactorLoginController.cs new file mode 100644 index 0000000000..ba86e63a36 --- /dev/null +++ b/src/Umbraco.Web.Website/Controllers/UmbTwoFactorLoginController.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Web.Common.ActionsResults; +using Umbraco.Cms.Web.Common.Filters; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; +using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; + +namespace Umbraco.Cms.Web.Website.Controllers +{ + [UmbracoMemberAuthorize] + public class UmbTwoFactorLoginController : SurfaceController + { + private readonly IMemberManager _memberManager; + private readonly ITwoFactorLoginService _twoFactorLoginService; + private readonly ILogger _logger; + private readonly IMemberSignInManagerExternalLogins _memberSignInManager; + + public UmbTwoFactorLoginController( + ILogger logger, + IUmbracoContextAccessor umbracoContextAccessor, + IUmbracoDatabaseFactory databaseFactory, + ServiceContext services, + AppCaches appCaches, + IProfilingLogger profilingLogger, + IPublishedUrlProvider publishedUrlProvider, + IMemberSignInManagerExternalLogins memberSignInManager, + IMemberManager memberManager, + ITwoFactorLoginService twoFactorLoginService) + : base( + umbracoContextAccessor, + databaseFactory, + services, + appCaches, + profilingLogger, + publishedUrlProvider) + { + _logger = logger; + _memberSignInManager = memberSignInManager; + _memberManager = memberManager; + _twoFactorLoginService = twoFactorLoginService; + } + + /// + /// Used to retrieve the 2FA providers for code submission + /// + /// + [AllowAnonymous] + public async Task>> Get2FAProviders() + { + var user = await _memberSignInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + _logger.LogWarning("Get2FAProviders :: No verified member found, returning 404"); + return NotFound(); + } + + var userFactors = await _memberManager.GetValidTwoFactorProvidersAsync(user); + return new ObjectResult(userFactors); + } + + [AllowAnonymous] + public async Task Verify2FACode(Verify2FACodeModel model, string returnUrl = null) + { + var user = await _memberSignInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + _logger.LogWarning("PostVerify2FACode :: No verified member found, returning 404"); + return NotFound(); + } + + if (ModelState.IsValid) + { + var result = await _memberSignInManager.TwoFactorSignInAsync(model.Provider, model.Code, model.IsPersistent, model.RememberClient); + if (result.Succeeded) + { + return RedirectToLocal(returnUrl); + } + + if (result.IsLockedOut) + { + ModelState.AddModelError(nameof(Verify2FACodeModel.Code), "Member is locked out"); + } + else if (result.IsNotAllowed) + { + ModelState.AddModelError(nameof(Verify2FACodeModel.Code), "Member is not allowed"); + } + else + { + ModelState.AddModelError(nameof(Verify2FACodeModel.Code), "Invalid code"); + } + } + + //We need to set this, to ensure we show the 2fa login page + var providerNames = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(user.Key); + ViewData.SetTwoFactorProviderNames(providerNames); + return CurrentUmbracoPage(); + } + + [HttpPost] + public async Task ValidateAndSaveSetup(string providerName, string secret, string code, string returnUrl = null) + { + var member = await _memberManager.GetCurrentMemberAsync(); + + var isValid = _twoFactorLoginService.ValidateTwoFactorSetup(providerName, secret, code); + + if (isValid == false) + { + ModelState.AddModelError(nameof(code), "Invalid Code"); + + return CurrentUmbracoPage(); + } + + var twoFactorLogin = new TwoFactorLogin() + { + Confirmed = true, + Secret = secret, + UserOrMemberKey = member.Key, + ProviderName = providerName + }; + + await _twoFactorLoginService.SaveAsync(twoFactorLogin); + + return RedirectToLocal(returnUrl); + } + + [HttpPost] + public async Task Disable(string providerName, string returnUrl = null) + { + var member = await _memberManager.GetCurrentMemberAsync(); + + var success = await _twoFactorLoginService.DisableAsync(member.Key, providerName); + + if (!success) + { + return CurrentUmbracoPage(); + } + + return RedirectToLocal(returnUrl); + } + + private IActionResult RedirectToLocal(string returnUrl) => + Url.IsLocalUrl(returnUrl) ? Redirect(returnUrl) : RedirectToCurrentUmbracoPage(); + } +} diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilder.MemberIdentity.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilder.MemberIdentity.cs new file mode 100644 index 0000000000..c208d96972 --- /dev/null +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilder.MemberIdentity.cs @@ -0,0 +1,23 @@ +using System; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Web.Website.Security; + + +namespace Umbraco.Extensions +{ + /// + /// Extension methods for for the Umbraco back office + /// + public static partial class UmbracoBuilderExtensions + { + /// + /// Adds support for external login providers in Umbraco + /// + public static IUmbracoBuilder AddMemberExternalLogins(this IUmbracoBuilder umbracoBuilder, Action builder) + { + builder(new MemberExternalLoginsBuilder(umbracoBuilder.Services)); + return umbracoBuilder; + } + + } +} diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs index 797a4b2202..5f8f1d9b69 100644 --- a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs @@ -18,7 +18,7 @@ namespace Umbraco.Extensions /// /// extensions for umbraco front-end website /// - public static class UmbracoBuilderExtensions + public static partial class UmbracoBuilderExtensions { /// /// Add services for the umbraco front-end website diff --git a/src/Umbraco.Web.Website/Models/ProfileModel.cs b/src/Umbraco.Web.Website/Models/ProfileModel.cs index a435d22c06..5fc7ed2df8 100644 --- a/src/Umbraco.Web.Website/Models/ProfileModel.cs +++ b/src/Umbraco.Web.Website/Models/ProfileModel.cs @@ -12,6 +12,10 @@ namespace Umbraco.Cms.Web.Website.Models /// public class ProfileModel : PostRedirectModel { + + [ReadOnly(true)] + public Guid Key { get; set; } + [Required] [EmailAddress] [Display(Name = "Email")] diff --git a/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs b/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs index 00d4cebd1e..4a94fb094c 100644 --- a/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs +++ b/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs @@ -68,7 +68,8 @@ namespace Umbraco.Cms.Web.Website.Models CreatedDate = member.CreatedDateUtc.ToLocalTime(), LastLoginDate = member.LastLoginDateUtc?.ToLocalTime(), LastPasswordChangedDate = member.LastPasswordChangeDateUtc?.ToLocalTime(), - RedirectUrl = _redirectUrl + RedirectUrl = _redirectUrl, + Key = member.Key }; IMemberType memberType = MemberTypeService.Get(member.MemberTypeAlias); @@ -83,7 +84,7 @@ namespace Umbraco.Cms.Web.Website.Models { // should never happen throw new InvalidOperationException($"Could not find a member with key: {member.Key}."); - } + } if (_lookupProperties) { diff --git a/src/Umbraco.Web.Website/Security/MemberAuthenticationBuilder.cs b/src/Umbraco.Web.Website/Security/MemberAuthenticationBuilder.cs new file mode 100644 index 0000000000..d58abfc871 --- /dev/null +++ b/src/Umbraco.Web.Website/Security/MemberAuthenticationBuilder.cs @@ -0,0 +1,75 @@ +using System; +using System.Diagnostics; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; +using Constants = Umbraco.Cms.Core.Constants; + +namespace Umbraco.Cms.Web.Website.Security +{ + /// + /// Custom used to associate external logins with umbraco external login options + /// + public class MemberAuthenticationBuilder : AuthenticationBuilder + { + private readonly Action _loginProviderOptions; + + public MemberAuthenticationBuilder( + IServiceCollection services, + Action loginProviderOptions = null) + : base(services) + => _loginProviderOptions = loginProviderOptions ?? (x => { }); + + public string SchemeForMembers(string scheme) + => scheme?.EnsureStartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix); + + /// + /// Overridden to track the final authenticationScheme being registered for the external login + /// + /// + /// + /// + /// + /// + /// + public override AuthenticationBuilder AddRemoteScheme(string authenticationScheme, string displayName, Action configureOptions) + { + // Validate that the prefix is set + if (!authenticationScheme.StartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix)) + { + throw new InvalidOperationException($"The {nameof(authenticationScheme)} is not prefixed with {Constants.Security.BackOfficeExternalAuthenticationTypePrefix}. The scheme must be created with a call to the method {nameof(SchemeForMembers)}"); + } + + // add our login provider to the container along with a custom options configuration + Services.Configure(authenticationScheme, _loginProviderOptions); + base.Services.AddSingleton(services => + { + return new MemberExternalLoginProvider( + authenticationScheme, + services.GetRequiredService>()); + }); + Services.TryAddEnumerable(ServiceDescriptor.Singleton, EnsureMemberScheme>()); + + return base.AddRemoteScheme(authenticationScheme, displayName, configureOptions); + } + + // Ensures that the sign in scheme is always the Umbraco member external type + private class EnsureMemberScheme : IPostConfigureOptions where TOptions : RemoteAuthenticationOptions + { + public void PostConfigure(string name, TOptions options) + { + if (!name.StartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix)) + { + return; + } + + options.SignInScheme = IdentityConstants.ExternalScheme; + } + } + } + +} diff --git a/src/Umbraco.Web.Website/Security/MemberExternalLoginsBuilder.cs b/src/Umbraco.Web.Website/Security/MemberExternalLoginsBuilder.cs new file mode 100644 index 0000000000..4f8eb407be --- /dev/null +++ b/src/Umbraco.Web.Website/Security/MemberExternalLoginsBuilder.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Web.Common.Security; + +namespace Umbraco.Cms.Web.Website.Security +{ + /// + /// Used to add back office login providers + /// + public class MemberExternalLoginsBuilder + { + public MemberExternalLoginsBuilder(IServiceCollection services) + { + _services = services; + } + + private readonly IServiceCollection _services; + + /// + /// Add a back office login provider with options + /// + /// + /// + /// + public MemberExternalLoginsBuilder AddMemberLogin( + Action build, + Action loginProviderOptions = null) + { + build(new MemberAuthenticationBuilder(_services, loginProviderOptions)); + return this; + } + } + +} diff --git a/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs b/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs new file mode 100644 index 0000000000..bc14f43235 --- /dev/null +++ b/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using Umbraco.Core; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.IO; +using Umbraco.Core.Services; +using Umbraco.Web.HealthCheck.Checks.Config; + +namespace Umbraco.Web.HealthCheck.Checks.Security +{ + [HealthCheck( + "6708CA45-E96E-40B8-A40A-0607C1CA7F28", + "Application URL Configuration", + Description = "Checks if the Umbraco application URL is configured for your site.", + Group = "Security")] + public class UmbracoApplicationUrlCheck : HealthCheck + { + private readonly ILocalizedTextService _textService; + private readonly IRuntimeState _runtime; + private readonly IUmbracoSettingsSection _settings; + + private const string SetApplicationUrlAction = "setApplicationUrl"; + + public UmbracoApplicationUrlCheck(ILocalizedTextService textService, IRuntimeState runtime, IUmbracoSettingsSection settings) + { + _textService = textService; + _runtime = runtime; + _settings = settings; + } + + /// + /// Executes the action and returns its status + /// + /// + /// + public override HealthCheckStatus ExecuteAction(HealthCheckAction action) + { + switch (action.Alias) + { + case SetApplicationUrlAction: + return SetUmbracoApplicationUrl(); + default: + throw new InvalidOperationException("UmbracoApplicationUrlCheck action requested is either not executable or does not exist"); + } + } + + public override IEnumerable GetStatus() + { + //return the statuses + return new[] { CheckUmbracoApplicationUrl() }; + } + + private HealthCheckStatus CheckUmbracoApplicationUrl() + { + var url = _settings.WebRouting.UmbracoApplicationUrl; + + string resultMessage; + StatusResultType resultType; + var actions = new List(); + + if (url.IsNullOrWhiteSpace()) + { + resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse"); + resultType = StatusResultType.Warning; + + actions.Add(new HealthCheckAction(SetApplicationUrlAction, Id) + { + Name = _textService.Localize("healthcheck", "umbracoApplicationUrlConfigureButton"), + Description = _textService.Localize("healthcheck", "umbracoApplicationUrlConfigureDescription") + }); + } + else + { + resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url }); + resultType = StatusResultType.Success; + } + + return new HealthCheckStatus(resultMessage) + { + ResultType = resultType, + Actions = actions + }; + } + + private HealthCheckStatus SetUmbracoApplicationUrl() + { + var configFilePath = IOHelper.MapPath("~/config/umbracoSettings.config"); + const string xPath = "/settings/web.routing/@umbracoApplicationUrl"; + var configurationService = new ConfigurationService(configFilePath, xPath, _textService); + var urlValue = _runtime.ApplicationUrl.ToString(); + var updateConfigFile = configurationService.UpdateConfigFile(urlValue); + + if (updateConfigFile.Success) + { + return + new HealthCheckStatus(_textService.Localize("healthcheck", "umbracoApplicationUrlConfigureSuccess", new[] { urlValue })) + { + ResultType = StatusResultType.Success + }; + } + + return + new HealthCheckStatus(_textService.Localize("healthcheck", "umbracoApplicationUrlConfigureError", new[] { updateConfigFile.Result })) + { + ResultType = StatusResultType.Error + }; + } + } +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/cypress/integration/Languages/languages.ts b/tests/Umbraco.Tests.AcceptanceTest/cypress/integration/Languages/languages.ts index 5898335105..33d5de24cb 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/cypress/integration/Languages/languages.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/cypress/integration/Languages/languages.ts @@ -13,6 +13,7 @@ context('Languages', () => { cy.umbracoEnsureLanguageCultureNotExists(culture); cy.umbracoSection('settings'); + cy.get('.umb-tree-root-link').contains('Settings') // Enter language tree and create new language cy.umbracoTreeItem('settings', ['Languages']).click(); cy.umbracoButtonByLabelKey('languages_addLanguage').click(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs index ab5ed1dcfc..3b719dc53a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs @@ -24,7 +24,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Security { private IUserService UserService => GetRequiredService(); private IEntityService EntityService => GetRequiredService(); - private IExternalLoginService ExternalLoginService => GetRequiredService(); + private IExternalLoginWithKeyService ExternalLoginService => GetRequiredService(); private IUmbracoMapper UmbracoMapper => GetRequiredService(); private ILocalizedTextService TextService => GetRequiredService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ExternalLoginServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ExternalLoginServiceTests.cs index b4f6c1ae5f..f0803b25d0 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ExternalLoginServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ExternalLoginServiceTests.cs @@ -20,7 +20,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services { private IUserService UserService => GetRequiredService(); - private IExternalLoginService ExternalLoginService => (IExternalLoginService)GetRequiredService(); + private IExternalLoginWithKeyService ExternalLoginService => GetRequiredService(); [Test] [Ignore("We don't support duplicates anymore, this removing on save was a breaking change work around, this needs to be ported to a migration")] @@ -38,14 +38,14 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services // insert duplicates manuall scope.Database.Insert(new ExternalLoginDto { - UserId = user.Id, + UserOrMemberKey = user.Key, LoginProvider = "test1", ProviderKey = providerKey, CreateDate = latest }); scope.Database.Insert(new ExternalLoginDto { - UserId = user.Id, + UserOrMemberKey = user.Key, LoginProvider = "test1", ProviderKey = providerKey, CreateDate = oldest @@ -60,9 +60,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", providerKey) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); - var logins = ExternalLoginService.GetExternalLogins(user.Id).ToList(); + var logins = ExternalLoginService.GetExternalLogins(user.Key).ToList(); // duplicates will be removed, keeping the latest entries Assert.AreEqual(2, logins.Count); @@ -84,9 +84,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", providerKey) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); - var logins = ExternalLoginService.GetExternalLogins(user.Id).ToList(); + var logins = ExternalLoginService.GetExternalLogins(user.Key).ToList(); Assert.AreEqual(1, logins.Count); } @@ -103,16 +103,16 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", providerKey1, "hello"), new ExternalLogin("test2", providerKey2, "world") }; - ExternalLoginService.Save(user.Id, extLogins); + ExternalLoginService.Save(user.Key, extLogins); extLogins = new[] { new ExternalLogin("test1", providerKey1, "123456"), new ExternalLogin("test2", providerKey2, "987654") }; - ExternalLoginService.Save(user.Id, extLogins); + ExternalLoginService.Save(user.Key, extLogins); - var found = ExternalLoginService.GetExternalLogins(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var found = ExternalLoginService.GetExternalLogins(user.Key).OrderBy(x => x.LoginProvider).ToList(); Assert.AreEqual(2, found.Count); Assert.AreEqual("123456", found[0].UserData); Assert.AreEqual("987654", found[1].UserData); @@ -131,7 +131,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", providerKey1, "hello"), new ExternalLogin("test2", providerKey2, "world") }; - ExternalLoginService.Save(user.Id, extLogins); + ExternalLoginService.Save(user.Key, extLogins); var found = ExternalLoginService.Find("test2", providerKey2).ToList(); Assert.AreEqual(1, found.Count); @@ -151,9 +151,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test2", Guid.NewGuid().ToString("N")) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); - var logins = ExternalLoginService.GetExternalLogins(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var logins = ExternalLoginService.GetExternalLogins(user.Key).OrderBy(x => x.LoginProvider).ToList(); Assert.AreEqual(2, logins.Count); for (int i = 0; i < logins.Count; i++) { @@ -173,7 +173,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", Guid.NewGuid().ToString("N")) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); ExternalLoginToken[] externalTokens = new[] { @@ -181,9 +181,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLoginToken(externalLogins[0].LoginProvider, "hello2", "world2") }; - ExternalLoginService.Save(user.Id, externalTokens); + ExternalLoginService.Save(user.Key, externalTokens); - var tokens = ExternalLoginService.GetExternalLoginTokens(user.Id).ToList(); + var tokens = ExternalLoginService.GetExternalLoginTokens(user.Key).ToList(); Assert.AreEqual(2, tokens.Count); } @@ -201,18 +201,18 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test4", Guid.NewGuid().ToString("N")) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); - var logins = ExternalLoginService.GetExternalLogins(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var logins = ExternalLoginService.GetExternalLogins(user.Key).OrderBy(x => x.LoginProvider).ToList(); logins.RemoveAt(0); // remove the first one logins.Add(new IdentityUserLogin("test5", Guid.NewGuid().ToString("N"), user.Id.ToString())); // add a new one logins[0].ProviderKey = "abcd123"; // update // save new list - ExternalLoginService.Save(user.Id, logins.Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey))); + ExternalLoginService.Save(user.Key, logins.Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey))); - var updatedLogins = ExternalLoginService.GetExternalLogins(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var updatedLogins = ExternalLoginService.GetExternalLogins(user.Key).OrderBy(x => x.LoginProvider).ToList(); Assert.AreEqual(4, updatedLogins.Count); for (int i = 0; i < updatedLogins.Count; i++) { @@ -233,7 +233,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test2", Guid.NewGuid().ToString("N")) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); ExternalLoginToken[] externalTokens = new[] { @@ -243,18 +243,18 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLoginToken(externalLogins[1].LoginProvider, "hello2a", "world2a") }; - ExternalLoginService.Save(user.Id, externalTokens); + ExternalLoginService.Save(user.Key, externalTokens); - var tokens = ExternalLoginService.GetExternalLoginTokens(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var tokens = ExternalLoginService.GetExternalLoginTokens(user.Key).OrderBy(x => x.LoginProvider).ToList(); tokens.RemoveAt(0); // remove the first one tokens.Add(new IdentityUserToken(externalLogins[1].LoginProvider, "hello2b", "world2b", user.Id.ToString())); // add a new one tokens[0].Value = "abcd123"; // update // save new list - ExternalLoginService.Save(user.Id, tokens.Select(x => new ExternalLoginToken(x.LoginProvider, x.Name, x.Value))); + ExternalLoginService.Save(user.Key, tokens.Select(x => new ExternalLoginToken(x.LoginProvider, x.Name, x.Value))); - var updatedTokens = ExternalLoginService.GetExternalLoginTokens(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var updatedTokens = ExternalLoginService.GetExternalLoginTokens(user.Key).OrderBy(x => x.LoginProvider).ToList(); Assert.AreEqual(4, updatedTokens.Count); for (int i = 0; i < updatedTokens.Count; i++) { @@ -275,9 +275,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", Guid.NewGuid().ToString("N"), "hello world") }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); - var logins = ExternalLoginService.GetExternalLogins(user.Id).ToList(); + var logins = ExternalLoginService.GetExternalLogins(user.Key).ToList(); Assert.AreEqual("hello world", logins[0].UserData); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs new file mode 100644 index 0000000000..f159ecbc85 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs @@ -0,0 +1,90 @@ +using System.Linq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models +{ + [TestFixture] + public class RequestHandlerSettingsTests + { + [Test] + public void Given_CharCollection_With_DefaultEnabled_MergesCollection() + { + var userCollection = new CharItem[] + { + new () { Char = "test", Replacement = "replace" }, + new () { Char = "test2", Replacement = "replace2" } + }; + + + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); + + var expectedCollection = RequestHandlerSettings.DefaultCharCollection.ToList(); + expectedCollection.AddRange(userCollection); + + Assert.AreEqual(expectedCollection.Count, actual.Count); + Assert.That(actual, Is.EquivalentTo(expectedCollection)); + } + + [Test] + public void Given_CharCollection_With_DefaultDisabled_ReturnsUserCollection() + { + var userCollection = new CharItem[] + { + new () { Char = "test", Replacement = "replace" }, + new () { Char = "test2", Replacement = "replace2" } + }; + + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection, EnableDefaultCharReplacements = false }; + var actual = settings.GetCharReplacements().ToList(); + + Assert.AreEqual(userCollection.Length, actual.Count); + Assert.That(actual, Is.EquivalentTo(userCollection)); + } + + [Test] + public void Given_CharCollection_That_OverridesDefaultValues_ReturnsReplacements() + { + var userCollection = new CharItem[] + { + new () { Char = "%", Replacement = "percent" }, + new () { Char = ".", Replacement = "dot" } + }; + + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); + + Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length, actual.Count); + + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "%" && x.Replacement == "percent")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "." && x.Replacement == "dot")); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "%" && x.Replacement == string.Empty)); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "." && x.Replacement == string.Empty)); + } + + [Test] + public void Given_CharCollection_That_OverridesDefaultValues_And_ContainsNew_ReturnsMergedWithReplacements() + { + var userCollection = new CharItem[] + { + new () { Char = "%", Replacement = "percent" }, + new () { Char = ".", Replacement = "dot" }, + new () { Char = "new", Replacement = "new" } + }; + + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); + + // Add 1 to the length, because we're expecting to only add one new one + Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length + 1, actual.Count); + + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "%" && x.Replacement == "percent")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "." && x.Replacement == "dot")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "new" && x.Replacement == "new")); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "%" && x.Replacement == string.Empty)); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "." && x.Replacement == string.Empty)); + } + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NestedContentPropertyComponentTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NestedContentPropertyComponentTests.cs index 0ada6a20dd..f32f252633 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NestedContentPropertyComponentTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NestedContentPropertyComponentTests.cs @@ -3,6 +3,7 @@ using System; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using NUnit.Framework; using Umbraco.Cms.Core.PropertyEditors; @@ -11,6 +12,11 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors [TestFixture] public class NestedContentPropertyComponentTests { + private static void AreEqualJson(string expected, string actual) + { + Assert.AreEqual(JToken.Parse(expected), JToken.Parse(actual)); + } + [Test] public void Invalid_Json() { @@ -27,17 +33,17 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors Guid GuidFactory() => guids[guidCounter++]; var json = @"[ - {""key"":""04a6dba8-813c-4144-8aca-86a3f24ebf08"",""name"":""Item 1"",""ncContentTypeAlias"":""nested"",""text"":""woot""}, - {""key"":""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"",""name"":""Item 2"",""ncContentTypeAlias"":""nested"",""text"":""zoot""} -]"; + {""key"":""04a6dba8-813c-4144-8aca-86a3f24ebf08"",""name"":""Item 1"",""ncContentTypeAlias"":""nested"",""text"":""woot""}, + {""key"":""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"",""name"":""Item 2"",""ncContentTypeAlias"":""nested"",""text"":""zoot""} + ]"; var expected = json .Replace("04a6dba8-813c-4144-8aca-86a3f24ebf08", guids[0].ToString()) .Replace("d8e214d8-c5a5-4b45-9b51-4050dd47f5fa", guids[1].ToString()); var component = new NestedContentPropertyHandler(); - var result = component.CreateNestedContentKeys(json, false, GuidFactory); + var actual = component.CreateNestedContentKeys(json, false, GuidFactory); - Assert.AreEqual(JsonConvert.DeserializeObject(expected).ToString(), JsonConvert.DeserializeObject(result).ToString()); + AreEqualJson(expected, actual); } [Test] @@ -48,29 +54,27 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors Guid GuidFactory() => guids[guidCounter++]; var json = @"[{ - ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", - ""name"": ""Item 1"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""woot"" - }, { - ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", - ""name"": ""Item 2"", - ""ncContentTypeAlias"": ""list"", - ""text"": ""zoot"", - ""subItems"": [{ - ""key"": ""dccf550c-3a05-469e-95e1-a8f560f788c2"", - ""name"": ""Item 1"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""woot"" - }, { - ""key"": ""fbde4288-8382-4e13-8933-ed9c160de050"", - ""name"": ""Item 2"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""zoot"" - } - ] - } -]"; + ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", + ""name"": ""Item 1"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""woot"" + }, { + ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", + ""name"": ""Item 2"", + ""ncContentTypeAlias"": ""list"", + ""text"": ""zoot"", + ""subItems"": [{ + ""key"": ""dccf550c-3a05-469e-95e1-a8f560f788c2"", + ""name"": ""Item 1"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""woot"" + }, { + ""key"": ""fbde4288-8382-4e13-8933-ed9c160de050"", + ""name"": ""Item 2"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""zoot"" + }] + }]"; var expected = json .Replace("04a6dba8-813c-4144-8aca-86a3f24ebf08", guids[0].ToString()) @@ -79,9 +83,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors .Replace("fbde4288-8382-4e13-8933-ed9c160de050", guids[3].ToString()); var component = new NestedContentPropertyHandler(); - var result = component.CreateNestedContentKeys(json, false, GuidFactory); + var actual = component.CreateNestedContentKeys(json, false, GuidFactory); - Assert.AreEqual(JsonConvert.DeserializeObject(expected).ToString(), JsonConvert.DeserializeObject(result).ToString()); + AreEqualJson(expected, actual); } [Test] @@ -93,7 +97,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing // and this is how to do that, the result will also include quotes around it. - var subJsonEscaped = JsonConvert.ToString(JsonConvert.DeserializeObject(@"[{ + var subJsonEscaped = JsonConvert.ToString(JToken.Parse(@" + [{ ""key"": ""dccf550c-3a05-469e-95e1-a8f560f788c2"", ""name"": ""Item 1"", ""ncContentTypeAlias"": ""text"", @@ -104,21 +109,21 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" } - ]").ToString()); + ]").ToString(Formatting.None)); var json = @"[{ - ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", - ""name"": ""Item 1"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""woot"" - }, { - ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", - ""name"": ""Item 2"", - ""ncContentTypeAlias"": ""list"", - ""text"": ""zoot"", - ""subItems"":" + subJsonEscaped + @" - } -]"; + ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", + ""name"": ""Item 1"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""woot"" + }, { + ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", + ""name"": ""Item 2"", + ""ncContentTypeAlias"": ""list"", + ""text"": ""zoot"", + ""subItems"":" + subJsonEscaped + @" + } + ]"; var expected = json .Replace("04a6dba8-813c-4144-8aca-86a3f24ebf08", guids[0].ToString()) @@ -127,9 +132,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors .Replace("fbde4288-8382-4e13-8933-ed9c160de050", guids[3].ToString()); var component = new NestedContentPropertyHandler(); - var result = component.CreateNestedContentKeys(json, false, GuidFactory); + var actual = component.CreateNestedContentKeys(json, false, GuidFactory); - Assert.AreEqual(JsonConvert.DeserializeObject(expected).ToString(), JsonConvert.DeserializeObject(result).ToString()); + AreEqualJson(expected, actual); } [Test] @@ -141,7 +146,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing // and this is how to do that, the result will also include quotes around it. - var subJsonEscaped = JsonConvert.ToString(JsonConvert.DeserializeObject(@"[{ + var subJsonEscaped = JsonConvert.ToString(JToken.Parse(@"[{ ""key"": ""dccf550c-3a05-469e-95e1-a8f560f788c2"", ""name"": ""Item 1"", ""ncContentTypeAlias"": ""text"", @@ -152,7 +157,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" } - ]").ToString()); + ]").ToString(Formatting.None)); // Complex editor such as the grid var complexEditorJsonEscaped = @"{ @@ -231,9 +236,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors .Replace("fbde4288-8382-4e13-8933-ed9c160de050", guids[3].ToString()); var component = new NestedContentPropertyHandler(); - var result = component.CreateNestedContentKeys(json, false, GuidFactory); + var actual = component.CreateNestedContentKeys(json, false, GuidFactory); - Assert.AreEqual(JsonConvert.DeserializeObject(expected).ToString(), JsonConvert.DeserializeObject(result).ToString()); + AreEqualJson(expected, actual); } [Test] @@ -252,10 +257,10 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors var result = component.CreateNestedContentKeys(json, true, GuidFactory); // Ensure the new GUID is put in a key into the JSON - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[0].ToString())); + Assert.IsTrue(result.Contains(guids[0].ToString())); // Ensure that the original key is NOT changed/modified & still exists - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains("04a6dba8-813c-4144-8aca-86a3f24ebf08")); + Assert.IsTrue(result.Contains("04a6dba8-813c-4144-8aca-86a3f24ebf08")); } [Test] @@ -267,7 +272,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing // and this is how to do that, the result will also include quotes around it. - var subJsonEscaped = JsonConvert.ToString(JsonConvert.DeserializeObject(@"[{ + var subJsonEscaped = JsonConvert.ToString(JToken.Parse(@"[{ ""name"": ""Item 1"", ""ncContentTypeAlias"": ""text"", ""text"": ""woot"" @@ -276,7 +281,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" } - ]").ToString()); + ]").ToString(Formatting.None)); var json = @"[{ ""name"": ""Item 1 was copied and has no key"", @@ -295,9 +300,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors var result = component.CreateNestedContentKeys(json, true, GuidFactory); // Ensure the new GUID is put in a key into the JSON for each item - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[0].ToString())); - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[1].ToString())); - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[2].ToString())); + Assert.IsTrue(result.Contains(guids[0].ToString())); + Assert.IsTrue(result.Contains(guids[1].ToString())); + Assert.IsTrue(result.Contains(guids[2].ToString())); } [Test] @@ -309,7 +314,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing // and this is how to do that, the result will also include quotes around it. - var subJsonEscaped = JsonConvert.ToString(JsonConvert.DeserializeObject(@"[{ + var subJsonEscaped = JsonConvert.ToString(JToken.Parse(@"[{ ""key"": ""dccf550c-3a05-469e-95e1-a8f560f788c2"", ""name"": ""Item 1"", ""ncContentTypeAlias"": ""text"", @@ -319,7 +324,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" } - ]").ToString()); + ]").ToString(Formatting.None)); // Complex editor such as the grid var complexEditorJsonEscaped = @"{ @@ -394,8 +399,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors var result = component.CreateNestedContentKeys(json, true, GuidFactory); // Ensure the new GUID is put in a key into the JSON for each item - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[0].ToString())); - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[1].ToString())); + Assert.IsTrue(result.Contains(guids[0].ToString())); + Assert.IsTrue(result.Contains(guids[1].ToString())); } } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs index 6f9ee481cc..b686aee278 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System; using System.Diagnostics; using System.Linq; using System.Text; @@ -19,7 +20,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Enumerable.Empty(), + CharCollection = Array.Empty(), + EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; @@ -45,7 +47,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Enumerable.Empty(), + CharCollection = Array.Empty(), + EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; @@ -339,7 +342,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Enumerable.Empty(), + CharCollection = Array.Empty(), + EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs index f3edb0b8c5..5fd34dae3f 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs @@ -53,7 +53,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security new UmbracoMapper(new MapDefinitionCollection(() => mapDefinitions), scopeProvider), scopeProvider, new IdentityErrorDescriber(), - Mock.Of()); + Mock.Of(), + Mock.Of(), + Mock.Of()); _mockIdentityOptions = new Mock>(); var idOptions = new IdentityOptions { Lockout = { AllowedForNewUsers = false } }; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs index db58239e5f..14261e34fb 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs @@ -37,7 +37,10 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security new UmbracoMapper(new MapDefinitionCollection(() => new List()), mockScopeProvider.Object), mockScopeProvider.Object, new IdentityErrorDescriber(), - Mock.Of()); + Mock.Of(), + Mock.Of(), + Mock.Of() + ); } [Test] diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/ImageCropperTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/ImageCropperTest.cs index f00225e7b4..f5d5d7c766 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/ImageCropperTest.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/ImageCropperTest.cs @@ -22,13 +22,13 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common { private const string CropperJson1 = "{\"focalPoint\": {\"left\": 0.96,\"top\": 0.80827067669172936},\"src\": \"/media/1005/img_0671.jpg\",\"crops\": [{\"alias\":\"thumb\",\"width\": 100,\"height\": 100,\"coordinates\": {\"x1\": 0.58729977382575338,\"y1\": 0.055768992440203169,\"x2\": 0,\"y2\": 0.32457553600198386}}]}"; private const string CropperJson2 = "{\"focalPoint\": {\"left\": 0.98,\"top\": 0.80827067669172936},\"src\": \"/media/1005/img_0672.jpg\",\"crops\": [{\"alias\":\"thumb\",\"width\": 100,\"height\": 100,\"coordinates\": {\"x1\": 0.58729977382575338,\"y1\": 0.055768992440203169,\"x2\": 0,\"y2\": 0.32457553600198386}}]}"; - private const string CropperJson3 = "{\"focalPoint\": {\"left\": 0.98,\"top\": 0.80827067669172936},\"src\": \"/media/1005/img_0672.jpg\",\"crops\": []}"; + private const string CropperJson3 = "{\"focalPoint\": {\"left\": 0.5,\"top\": 0.5},\"src\": \"/media/1005/img_0672.jpg\",\"crops\": []}"; private const string MediaPath = "/media/1005/img_0671.jpg"; [Test] public void CanConvertImageCropperDataSetSrcToString() { - // cropperJson3 - has not crops + // cropperJson3 - has no crops ImageCropperValue cropperValue = CropperJson3.DeserializeImageCropperValue(); Attempt serialized = cropperValue.TryConvertTo(); Assert.IsTrue(serialized.Success); @@ -38,7 +38,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common [Test] public void CanConvertImageCropperDataSetJObject() { - // cropperJson3 - has not crops + // cropperJson3 - has no crops ImageCropperValue cropperValue = CropperJson3.DeserializeImageCropperValue(); Attempt serialized = cropperValue.TryConvertTo(); Assert.IsTrue(serialized.Success); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs index f553919d08..192bcaf27e 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Net; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -38,7 +39,22 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Security serviceCollection .AddLogging() .AddAuthentication() - .AddCookie(IdentityConstants.ApplicationScheme); + .AddCookie(IdentityConstants.ApplicationScheme) + .AddCookie(IdentityConstants.ExternalScheme, o => + { + o.Cookie.Name = IdentityConstants.ExternalScheme; + o.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }) + .AddCookie(IdentityConstants.TwoFactorUserIdScheme, o => + { + o.Cookie.Name = IdentityConstants.TwoFactorUserIdScheme; + o.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }) + .AddCookie(IdentityConstants.TwoFactorRememberMeScheme, o => + { + o.Cookie.Name = IdentityConstants.TwoFactorRememberMeScheme; + o.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }); IServiceProvider serviceProvider = serviceProviderFactory.CreateServiceProvider(serviceCollection); var httpContextFactory = new DefaultHttpContextFactory(serviceProvider); IFeatureCollection features = new DefaultHttpContext().Features; @@ -56,7 +72,10 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Security Mock.Of>(), _mockLogger.Object, Mock.Of(), - Mock.Of>()); + Mock.Of>(), + Mock.Of(), + Mock.Of() + ); } private static Mock MockMemberManager() => new Mock(