diff --git a/src/SolutionInfo.cs b/src/SolutionInfo.cs index 3f512bc65a..f829770a59 100644 --- a/src/SolutionInfo.cs +++ b/src/SolutionInfo.cs @@ -18,5 +18,5 @@ using System.Resources; [assembly: AssemblyVersion("8.0.0")] // these are FYI and changed automatically -[assembly: AssemblyFileVersion("8.8.0")] -[assembly: AssemblyInformationalVersion("8.8.0")] +[assembly: AssemblyFileVersion("8.9.0")] +[assembly: AssemblyInformationalVersion("8.9.0-rc")] diff --git a/src/Umbraco.Core/Constants-Security.cs b/src/Umbraco.Core/Constants-Security.cs index 43c989805b..f900288ef5 100644 --- a/src/Umbraco.Core/Constants-Security.cs +++ b/src/Umbraco.Core/Constants-Security.cs @@ -56,6 +56,8 @@ namespace Umbraco.Core public const string AllowedApplicationsClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/allowedapp"; public const string SessionIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/sessionid"; + public const string BackOfficeExternalLoginOptionsProperty = "UmbracoBackOfficeExternalLoginOptions"; + } } } diff --git a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs index cb35783ffa..c001df91fd 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs @@ -7,6 +7,7 @@ using Umbraco.Core.Migrations.Upgrade.V_8_0_0; using Umbraco.Core.Migrations.Upgrade.V_8_0_1; using Umbraco.Core.Migrations.Upgrade.V_8_1_0; using Umbraco.Core.Migrations.Upgrade.V_8_6_0; +using Umbraco.Core.Migrations.Upgrade.V_8_8_0; using Umbraco.Core.Migrations.Upgrade.V_8_9_0; namespace Umbraco.Core.Migrations.Upgrade @@ -195,6 +196,9 @@ namespace Umbraco.Core.Migrations.Upgrade // to 8.7.0... To("{a78e3369-8ea3-40ec-ad3f-5f76929d2b20}"); + // to 8.8.0 + To("{B5838FF5-1D22-4F6C-BCEB-F83ACB14B575}"); + // to 8.9.0... To("{8DDDCD0B-D7D5-4C97-BD6A-6B38CA65752F}"); To("{4695D0C9-0729-4976-985B-048D503665D8}"); diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_8_0/ExternalLoginTableUserData.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_8_0/ExternalLoginTableUserData.cs new file mode 100644 index 0000000000..882dfdc2af --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_8_0/ExternalLoginTableUserData.cs @@ -0,0 +1,20 @@ +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_8_8_0 +{ + public class ExternalLoginTableUserData : MigrationBase + { + public ExternalLoginTableUserData(IMigrationContext context) + : base(context) + { + } + + /// + /// Adds new column to the External Login table + /// + public override void Migrate() + { + AddColumn(Constants.DatabaseSchema.Tables.ExternalLogin, "userData"); + } + } +} diff --git a/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs index 8dc056d555..b7bde26fae 100644 --- a/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs @@ -275,16 +275,23 @@ namespace Umbraco.Core.Models.Identity { get { - if (_getLogins != null && _getLogins.IsValueCreated == false) + // return if it exists + if (_logins != null) return _logins; + + _logins = new ObservableCollection(); + + // if the callback is there and hasn't been created yet then execute it and populate the logins + if (_getLogins != null && !_getLogins.IsValueCreated) { - _logins = new ObservableCollection(); foreach (var l in _getLogins.Value) { _logins.Add(l); } - //now assign events - _logins.CollectionChanged += Logins_CollectionChanged; } + + //now assign events + _logins.CollectionChanged += Logins_CollectionChanged; + return _logins; } } diff --git a/src/Umbraco.Core/Models/Identity/ExternalLogin.cs b/src/Umbraco.Core/Models/Identity/ExternalLogin.cs new file mode 100644 index 0000000000..6e4abf2906 --- /dev/null +++ b/src/Umbraco.Core/Models/Identity/ExternalLogin.cs @@ -0,0 +1,24 @@ +using System; + +namespace Umbraco.Core.Models.Identity +{ + /// + public class ExternalLogin : IExternalLogin + { + public ExternalLogin(string loginProvider, string providerKey, string userData = null) + { + LoginProvider = loginProvider ?? throw new ArgumentNullException(nameof(loginProvider)); + ProviderKey = providerKey ?? throw new ArgumentNullException(nameof(providerKey)); + UserData = userData; + } + + /// + public string LoginProvider { get; } + + /// + public string ProviderKey { get; } + + /// + public string UserData { get; } + } +} diff --git a/src/Umbraco.Core/Models/Identity/IExternalLogin.cs b/src/Umbraco.Core/Models/Identity/IExternalLogin.cs new file mode 100644 index 0000000000..68f66a5cee --- /dev/null +++ b/src/Umbraco.Core/Models/Identity/IExternalLogin.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Core.Models.Identity +{ + /// + /// Used to persist external login data for a user + /// + public interface IExternalLogin + { + string LoginProvider { get; } + string ProviderKey { get; } + string UserData { get; } + } +} diff --git a/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs b/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs index 276f601771..feb8af24f3 100644 --- a/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs +++ b/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs @@ -2,23 +2,30 @@ namespace Umbraco.Core.Models.Identity { + // TODO: Merge these in v8! This is here purely for backward compat + + public interface IIdentityUserLoginExtended : IIdentityUserLogin + { + /// + /// Used to store any arbitrary data for the user and external provider - like user tokens returned from the provider + /// + string UserData { get; set; } + } + public interface IIdentityUserLogin : IEntity, IRememberBeingDirty { /// /// The login provider for the login (i.e. Facebook, Google) - /// /// string LoginProvider { get; set; } /// /// Key representing the login for the provider - /// /// string ProviderKey { get; set; } /// /// User Id for the user who owns this login - /// /// int UserId { get; set; } } diff --git a/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs b/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs index 5876f420b4..66911b08ac 100644 --- a/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs +++ b/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs @@ -3,11 +3,11 @@ using Umbraco.Core.Models.Entities; namespace Umbraco.Core.Models.Identity { + /// /// Entity type for a user's login (i.e. Facebook, Google) - /// /// - public class IdentityUserLogin : EntityBase, IIdentityUserLogin + public class IdentityUserLogin : EntityBase, IIdentityUserLoginExtended { public IdentityUserLogin(string loginProvider, string providerKey, int userId) { @@ -25,22 +25,16 @@ namespace Umbraco.Core.Models.Identity CreateDate = createDate; } - /// - /// The login provider for the login (i.e. Facebook, Google) - /// - /// + /// public string LoginProvider { get; set; } - /// - /// Key representing the login for the provider - /// - /// + /// public string ProviderKey { get; set; } - /// - /// User Id for the user who owns this login - /// - /// + /// public int UserId { get; set; } + + /// + public string UserData { get; set; } } } diff --git a/src/Umbraco.Core/Persistence/Dtos/ExternalLoginDto.cs b/src/Umbraco.Core/Persistence/Dtos/ExternalLoginDto.cs index 1b774854a6..0a56552000 100644 --- a/src/Umbraco.Core/Persistence/Dtos/ExternalLoginDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/ExternalLoginDto.cs @@ -14,9 +14,13 @@ namespace Umbraco.Core.Persistence.Dtos [PrimaryKeyColumn(Name = "PK_umbracoExternalLogin")] public int Id { get; set; } + // TODO: This is completely missing a FK!!? + [Column("userId")] public int UserId { get; set; } + // TODO: There should be an index on both LoginProvider and ProviderKey + [Column("loginProvider")] [Length(4000)] [NullSetting(NullSetting = NullSettings.NotNull)] @@ -30,5 +34,13 @@ namespace Umbraco.Core.Persistence.Dtos [Column("createDate")] [Constraint(Default = SystemMethods.CurrentDateTime)] public DateTime CreateDate { get; set; } + + /// + /// Used to store any arbitrary data for the user and external provider - like user tokens returned from the provider + /// + [Column("userData")] + [NullSetting(NullSetting = NullSettings.Null)] + [SpecialDbType(SpecialDbTypes.NTEXT)] + public string UserData { get; set; } } } diff --git a/src/Umbraco.Core/Persistence/Factories/ExternalLoginFactory.cs b/src/Umbraco.Core/Persistence/Factories/ExternalLoginFactory.cs index 4309fe511f..6c1af68acd 100644 --- a/src/Umbraco.Core/Persistence/Factories/ExternalLoginFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/ExternalLoginFactory.cs @@ -1,13 +1,17 @@ -using Umbraco.Core.Models.Identity; +using System; +using Umbraco.Core.Models.Identity; using Umbraco.Core.Persistence.Dtos; namespace Umbraco.Core.Persistence.Factories { internal static class ExternalLoginFactory { - public static IIdentityUserLogin BuildEntity(ExternalLoginDto dto) + public static IIdentityUserLoginExtended BuildEntity(ExternalLoginDto dto) { - var entity = new IdentityUserLogin(dto.Id, dto.LoginProvider, dto.ProviderKey, dto.UserId, dto.CreateDate); + var entity = new IdentityUserLogin(dto.Id, dto.LoginProvider, dto.ProviderKey, dto.UserId, dto.CreateDate) + { + UserData = dto.UserData + }; // reset dirty initial properties (U4-1946) entity.ResetDirtyProperties(false); @@ -16,13 +20,30 @@ namespace Umbraco.Core.Persistence.Factories public static ExternalLoginDto BuildDto(IIdentityUserLogin entity) { + var asExtended = entity as IIdentityUserLoginExtended; var dto = new ExternalLoginDto { Id = entity.Id, CreateDate = entity.CreateDate, LoginProvider = entity.LoginProvider, ProviderKey = entity.ProviderKey, - UserId = entity.UserId + UserId = entity.UserId, + UserData = asExtended?.UserData + }; + + return dto; + } + + public static ExternalLoginDto BuildDto(int userId, IExternalLogin entity, int? id = null) + { + var dto = new ExternalLoginDto + { + Id = id ?? default, + UserId = userId, + LoginProvider = entity.LoginProvider, + ProviderKey = entity.ProviderKey, + UserData = entity.UserData, + CreateDate = DateTime.Now }; return dto; diff --git a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs index 6d145e9961..a5a93449d3 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Microsoft.AspNet.Identity; using Umbraco.Core.Models.Identity; @@ -6,7 +7,9 @@ namespace Umbraco.Core.Persistence.Repositories { public interface IExternalLoginRepository : IReadWriteQueryRepository { + [Obsolete("Use the overload specifying IIdentityUserLoginExtended instead")] void SaveUserLogins(int memberId, IEnumerable logins); + void Save(int userId, IEnumerable logins); void DeleteUserLogins(int memberId); } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/ExternalLoginRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/ExternalLoginRepository.cs index f708590ea8..ad53a2d522 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/ExternalLoginRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/ExternalLoginRepository.cs @@ -22,25 +22,65 @@ namespace Umbraco.Core.Persistence.Repositories.Implement public void DeleteUserLogins(int memberId) { - Database.Execute("DELETE FROM ExternalLogins WHERE UserId=@userId", new { userId = memberId }); + Database.Delete("WHERE userId=@userId", new { userId = memberId }); + } + + public void Save(int userId, IEnumerable logins) + { + var sql = Sql() + .Select() + .From() + .Where(x => x.UserId == userId) + .ForUpdate(); + + // deduplicate the logins + logins = logins.DistinctBy(x => x.ProviderKey + x.LoginProvider).ToList(); + + var toUpdate = new Dictionary(); + var toDelete = new List(); + var toInsert = new List(logins); + + var existingLogins = Database.Query(sql).OrderByDescending(x => x.CreateDate).ToList(); + // used to track duplicates so they can be removed + var keys = new HashSet<(string, string)>(); + + foreach (var existing in existingLogins) + { + if (!keys.Add((existing.ProviderKey, existing.LoginProvider))) + { + // if it already exists we need to remove this one + toDelete.Add(existing.Id); + } + else + { + var found = logins.FirstOrDefault(x => + x.LoginProvider.Equals(existing.LoginProvider, StringComparison.InvariantCultureIgnoreCase) + && x.ProviderKey.Equals(existing.ProviderKey, StringComparison.InvariantCultureIgnoreCase)); + + if (found != null) + { + toUpdate.Add(existing.Id, found); + // if it's an update then it's not an insert + toInsert.RemoveAll(x => x.ProviderKey == found.ProviderKey && x.LoginProvider == found.LoginProvider); + } + else + { + toDelete.Add(existing.Id); + } + } + } + + // do the deletes, updates and inserts + if (toDelete.Count > 0) + Database.DeleteMany().Where(x => toDelete.Contains(x.Id)).Execute(); + foreach (var u in toUpdate) + Database.Update(ExternalLoginFactory.BuildDto(userId, u.Value, u.Key)); + Database.InsertBulk(toInsert.Select(i => ExternalLoginFactory.BuildDto(userId, i))); } public void SaveUserLogins(int memberId, IEnumerable logins) { - //clear out logins for member - Database.Execute("DELETE FROM umbracoExternalLogin WHERE userId=@userId", new { userId = memberId }); - - //add them all - foreach (var l in logins) - { - Database.Insert(new ExternalLoginDto - { - LoginProvider = l.LoginProvider, - ProviderKey = l.ProviderKey, - UserId = memberId, - CreateDate = DateTime.Now - }); - } + Save(memberId, logins.Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey))); } protected override IIdentityUserLogin PerformGet(int id) @@ -67,7 +107,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement return PerformGetAllOnIds(ids); } - var sql = GetBaseQuery(false); + var sql = GetBaseQuery(false).OrderByDescending(x => x.CreateDate); return ConvertFromDtos(Database.Fetch(sql)) .ToArray();// we don't want to re-iterate again! @@ -103,7 +143,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement foreach (var dto in dtos) { - yield return Get(dto.Id); + yield return ExternalLoginFactory.BuildEntity(dto); } } diff --git a/src/Umbraco.Core/Security/BackOfficeUserStore.cs b/src/Umbraco.Core/Security/BackOfficeUserStore.cs index dc271452e1..7df328b5b7 100644 --- a/src/Umbraco.Core/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Core/Security/BackOfficeUserStore.cs @@ -96,9 +96,10 @@ namespace Umbraco.Core.Security IsLockedOut = user.IsLockedOut, }; - UpdateMemberProperties(userEntity, user); + // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. + var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(BackOfficeIdentityUser.Logins)); - // TODO: We should deal with Roles --> User Groups here which we currently are not doing + UpdateMemberProperties(userEntity, user); _userService.Save(userEntity); @@ -107,6 +108,16 @@ namespace Umbraco.Core.Security //re-assign id user.Id = userEntity.Id; + if (isLoginsPropertyDirty) + { + _externalLoginService.Save( + user.Id, + user.Logins.Select(x => new ExternalLogin( + x.LoginProvider, + x.ProviderKey, + (x is IIdentityUserLoginExtended extended) ? extended.UserData : null))); + } + return Task.FromResult(0); } @@ -115,7 +126,7 @@ namespace Umbraco.Core.Security /// /// /// - public async Task UpdateAsync(BackOfficeIdentityUser user) + public Task UpdateAsync(BackOfficeIdentityUser user) { ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); @@ -126,11 +137,13 @@ namespace Umbraco.Core.Security throw new InvalidOperationException("The user id must be an integer to work with the Umbraco"); } + // TODO: Wrap this in a scope! + var found = _userService.GetUserById(asInt.Result); if (found != null) { // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. - var isLoginsPropertyDirty = user.IsPropertyDirty("Logins"); + var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(BackOfficeIdentityUser.Logins)); if (UpdateMemberProperties(found, user)) { @@ -139,10 +152,16 @@ namespace Umbraco.Core.Security if (isLoginsPropertyDirty) { - var logins = await GetLoginsAsync(user); - _externalLoginService.SaveUserLogins(found.Id, logins); + _externalLoginService.Save( + found.Id, + user.Logins.Select(x => new ExternalLogin( + x.LoginProvider, + x.ProviderKey, + (x is IIdentityUserLoginExtended extended) ? extended.UserData : null))); } } + + return Task.CompletedTask; } /// @@ -382,7 +401,7 @@ namespace Umbraco.Core.Security if (login == null) throw new ArgumentNullException(nameof(login)); //get all logins associated with the login id - var result = _externalLoginService.Find(login).ToArray(); + var result = _externalLoginService.Find(login.LoginProvider, login.ProviderKey).ToArray(); if (result.Any()) { //return the first user that matches the result @@ -633,7 +652,7 @@ namespace Umbraco.Core.Security //don't assign anything if nothing has changed as this will trigger the track changes of the model - if (identityUser.IsPropertyDirty("LastLoginDateUtc") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.LastLoginDateUtc)) || (user.LastLoginDate != default(DateTime) && identityUser.LastLoginDateUtc.HasValue == false) || identityUser.LastLoginDateUtc.HasValue && user.LastLoginDate.ToUniversalTime() != identityUser.LastLoginDateUtc.Value) { @@ -642,33 +661,33 @@ namespace Umbraco.Core.Security var dt = identityUser.LastLoginDateUtc == DateTime.MinValue ? DateTime.MinValue : identityUser.LastLoginDateUtc.Value.ToLocalTime(); user.LastLoginDate = dt; } - if (identityUser.IsPropertyDirty("LastPasswordChangeDateUtc") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.LastPasswordChangeDateUtc)) || (user.LastPasswordChangeDate != default(DateTime) && identityUser.LastPasswordChangeDateUtc.HasValue == false) || identityUser.LastPasswordChangeDateUtc.HasValue && user.LastPasswordChangeDate.ToUniversalTime() != identityUser.LastPasswordChangeDateUtc.Value) { anythingChanged = true; user.LastPasswordChangeDate = identityUser.LastPasswordChangeDateUtc.Value.ToLocalTime(); } - if (identityUser.IsPropertyDirty("EmailConfirmed") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.EmailConfirmed)) || (user.EmailConfirmedDate.HasValue && user.EmailConfirmedDate.Value != default(DateTime) && identityUser.EmailConfirmed == false) || ((user.EmailConfirmedDate.HasValue == false || user.EmailConfirmedDate.Value == default(DateTime)) && identityUser.EmailConfirmed)) { anythingChanged = true; user.EmailConfirmedDate = identityUser.EmailConfirmed ? (DateTime?)DateTime.Now : null; } - if (identityUser.IsPropertyDirty("Name") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Name)) && user.Name != identityUser.Name && identityUser.Name.IsNullOrWhiteSpace() == false) { anythingChanged = true; user.Name = identityUser.Name; } - if (identityUser.IsPropertyDirty("Email") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Email)) && user.Email != identityUser.Email && identityUser.Email.IsNullOrWhiteSpace() == false) { anythingChanged = true; user.Email = identityUser.Email; } - if (identityUser.IsPropertyDirty("AccessFailedCount") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.AccessFailedCount)) && user.FailedPasswordAttempts != identityUser.AccessFailedCount) { anythingChanged = true; @@ -686,32 +705,32 @@ namespace Umbraco.Core.Security } } - if (identityUser.IsPropertyDirty("UserName") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.UserName)) && user.Username != identityUser.UserName && identityUser.UserName.IsNullOrWhiteSpace() == false) { anythingChanged = true; user.Username = identityUser.UserName; } - if (identityUser.IsPropertyDirty("PasswordHash") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.PasswordHash)) && user.RawPasswordValue != identityUser.PasswordHash && identityUser.PasswordHash.IsNullOrWhiteSpace() == false) { anythingChanged = true; user.RawPasswordValue = identityUser.PasswordHash; } - if (identityUser.IsPropertyDirty("Culture") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Culture)) && user.Language != identityUser.Culture && identityUser.Culture.IsNullOrWhiteSpace() == false) { anythingChanged = true; user.Language = identityUser.Culture; } - if (identityUser.IsPropertyDirty("StartMediaIds") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.StartMediaIds)) && user.StartMediaIds.UnsortedSequenceEqual(identityUser.StartMediaIds) == false) { anythingChanged = true; user.StartMediaIds = identityUser.StartMediaIds; } - if (identityUser.IsPropertyDirty("StartContentIds") + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.StartContentIds)) && user.StartContentIds.UnsortedSequenceEqual(identityUser.StartContentIds) == false) { anythingChanged = true; @@ -724,7 +743,7 @@ namespace Umbraco.Core.Security } // TODO: Fix this for Groups too - if (identityUser.IsPropertyDirty("Roles") || identityUser.IsPropertyDirty("Groups")) + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Roles)) || identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Groups))) { var userGroupAliases = user.Groups.Select(x => x.Alias).ToArray(); diff --git a/src/Umbraco.Core/Security/MembershipProviderBase.cs b/src/Umbraco.Core/Security/MembershipProviderBase.cs index 633e12bcc1..0bc8de492a 100644 --- a/src/Umbraco.Core/Security/MembershipProviderBase.cs +++ b/src/Umbraco.Core/Security/MembershipProviderBase.cs @@ -31,7 +31,9 @@ namespace Umbraco.Core.Security public bool VerifyPassword(string password, string hashedPassword) { - if (string.IsNullOrWhiteSpace(hashedPassword)) throw new ArgumentException("Value cannot be null or whitespace.", "hashedPassword"); + if (string.IsNullOrWhiteSpace(hashedPassword)) + return false; + return CheckPassword(password, hashedPassword); } diff --git a/src/Umbraco.Core/Services/IExternalLoginService.cs b/src/Umbraco.Core/Services/IExternalLoginService.cs index a81543cf2d..2749ca7b8b 100644 --- a/src/Umbraco.Core/Services/IExternalLoginService.cs +++ b/src/Umbraco.Core/Services/IExternalLoginService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Microsoft.AspNet.Identity; using Umbraco.Core.Models.Identity; @@ -16,20 +17,38 @@ namespace Umbraco.Core.Services /// IEnumerable GetAll(int userId); + [Obsolete("Use the overload specifying loginProvider and providerKey instead")] + IEnumerable Find(UserLoginInfo login); + /// /// 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(UserLoginInfo login); + IEnumerable Find(string loginProvider, string providerKey); + + [Obsolete("Use the Save method instead")] + void SaveUserLogins(int userId, IEnumerable logins); /// - /// Save user logins + /// Saves the external logins associated with the user /// - /// + /// + /// The user associated with the logins + /// /// - void SaveUserLogins(int userId, IEnumerable logins); + /// + /// This will replace all external login provider information for the user + /// + void Save(int userId, IEnumerable logins); + + /// + /// Save a single external login record + /// + /// + void Save(IIdentityUserLoginExtended login); /// /// Deletes all user logins - normally used when a member is deleted diff --git a/src/Umbraco.Core/Services/Implement/ExternalLoginService.cs b/src/Umbraco.Core/Services/Implement/ExternalLoginService.cs index aedf3874dd..b0d9a80a65 100644 --- a/src/Umbraco.Core/Services/Implement/ExternalLoginService.cs +++ b/src/Umbraco.Core/Services/Implement/ExternalLoginService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Microsoft.AspNet.Identity; using Umbraco.Core.Events; @@ -20,53 +21,60 @@ namespace Umbraco.Core.Services.Implement _externalLoginRepository = externalLoginRepository; } - /// - /// Returns all user logins assigned - /// - /// - /// + /// public IEnumerable GetAll(int userId) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { return _externalLoginRepository.Get(Query().Where(x => x.UserId == userId)) - .ToList(); // ToList is important here, must evaluate within uow! // ToList is important here, must evaluate within uow! + .ToList(); } } - /// - /// 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 - /// - /// - /// + [Obsolete("Use the overload specifying loginProvider and providerKey instead")] public IEnumerable Find(UserLoginInfo login) + { + return Find(login.LoginProvider, login.ProviderKey); + } + + /// + public IEnumerable Find(string loginProvider, string providerKey) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { - return _externalLoginRepository.Get(Query().Where(x => x.ProviderKey == login.ProviderKey && x.LoginProvider == login.LoginProvider)) - .ToList(); // ToList is important here, must evaluate within uow! // ToList is important here, must evaluate within uow! + return _externalLoginRepository.Get(Query() + .Where(x => x.ProviderKey == providerKey && x.LoginProvider == loginProvider)) + .ToList(); } } - /// - /// Save user logins - /// - /// - /// + [Obsolete("Use the Save method instead")] public void SaveUserLogins(int userId, IEnumerable logins) + { + Save(userId, logins.Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey))); + } + + /// + public void Save(int userId, IEnumerable logins) { using (var scope = ScopeProvider.CreateScope()) { - _externalLoginRepository.SaveUserLogins(userId, logins); + _externalLoginRepository.Save(userId, logins); scope.Complete(); } } - /// - /// Deletes all user logins - normally used when a member is deleted - /// - /// + /// + public void Save(IIdentityUserLoginExtended login) + { + using (var scope = ScopeProvider.CreateScope()) + { + _externalLoginRepository.Save(login); + scope.Complete(); + } + } + + /// public void DeleteUserLogins(int userId) { using (var scope = ScopeProvider.CreateScope()) @@ -75,5 +83,7 @@ namespace Umbraco.Core.Services.Implement scope.Complete(); } } + + } } diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 444ef1a464..7615caa2ca 100755 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -134,6 +134,7 @@ + @@ -145,6 +146,8 @@ + + diff --git a/src/Umbraco.Tests.AcceptanceTest/README.md b/src/Umbraco.Tests.AcceptanceTest/README.md index 541efd40f8..f4edaa92ea 100644 --- a/src/Umbraco.Tests.AcceptanceTest/README.md +++ b/src/Umbraco.Tests.AcceptanceTest/README.md @@ -1,34 +1,36 @@ -# Umbraco Acceptance Tests - -### Prerequisites -- NodeJS 12+ -- A running installed Umbraco on url: [https://localhost:44331](https://localhost:44331) (Default development port) - - Install using a `SqlServer`/`LocalDb` as the tests execute too fast for `SqlCE` to handle. -- User information in `cypress.env.json` (See [Getting started](#getting-started)) - -### Getting started -The tests are located in the project/folder as `Umbraco.Tests.AcceptanceTests`. Make sure you run `npm install` in that folder, or let your IDE do that. - -Next, it is important that you create a new file in the root of the project called `cypress.env.json`. -This file is already added to `.gitignore` and can contain values that are different for each developer machine. - -The file needs the following content: -``` -{ - "username": "", - "password": "" -} -``` -Replace the `` and `` placeholders with correct info. - - - -### Executing tests -There are two npm scripts that can be used to execute the test: - -1. `npm run test` - - Executes the tests headless. -1. `npm run ui` - - Executes the tests in a browser handled by a cypress application. - - In case of errors it is recommended to use the UI to debug. +# Umbraco Acceptance Tests + +### Prerequisites +- NodeJS 12+ +- A running installed Umbraco on url: [https://localhost:44331](https://localhost:44331) (Default development port) + - Install using a `SqlServer`/`LocalDb` as the tests execute too fast for `SqlCE` to handle. + +### Getting started +The tests are located in the project/folder as `Umbraco.Tests.AcceptanceTests`. Make sure you run `npm install` in that folder, or let your IDE do that. + +The script will ask you to enter the username and password for a superadmin user of your Umbraco CMS. + +### Executing tests +There are two npm scripts that can be used to execute the test: + +1. `npm run test` + - Executes the tests headless. +1. `npm run ui` + - Executes the tests in a browser handled by a cypress application. + + In case of errors it is recommended to use the UI to debug. + +### Enviroment Configuration + +The enviroment configuration is begin setup by the npm installation script. +This results in the creation of this file: `cypress.env.json`. +This file is already added to `.gitignore` and can contain values that are different for each developer machine. + +The file has the following content: +``` +{ + "username": "", + "password": "" +} +``` +You can change this if you like or run the config script to reset the values, type "npm run config" in your terminal. diff --git a/src/Umbraco.Tests.AcceptanceTest/config.js b/src/Umbraco.Tests.AcceptanceTest/config.js new file mode 100644 index 0000000000..5297cbccbc --- /dev/null +++ b/src/Umbraco.Tests.AcceptanceTest/config.js @@ -0,0 +1,49 @@ +const prompt = require('prompt'); +const fs = require('fs'); + +const properties = [ + { + description: 'Enter your superadmin username/email', + name: 'username', + required: true + }, + { + description: 'Enter your superadmin password', + name: 'password', + hidden: true, + required: true + }, + { + description: 'Enter CMS URL, or leave empty for default(https://localhost:44331)', + name: 'baseUrl' + } +]; + + +const configPath = './cypress.env.json' + +console.log("Configure your test enviroment") + +prompt.start(); + +prompt.get(properties, function (error, result) { + if (error) { return onError(error); } + +var fileContent = `{ + "username": "${result.username}", + "password": "${result.password}"${ + result.baseUrl && `, + "baseUrl": "${result.baseUrl}"` + } +}`; + + fs.writeFile(configPath, fileContent, function (error) { + if (error) return console.error(error); + console.log('Configuration saved'); + }); +}); + +function onError(error) { + console.error(error); + return true; +} diff --git a/src/Umbraco.Tests.AcceptanceTest/cypress/plugins/index.js b/src/Umbraco.Tests.AcceptanceTest/cypress/plugins/index.js index aa9918d215..59283feec5 100644 --- a/src/Umbraco.Tests.AcceptanceTest/cypress/plugins/index.js +++ b/src/Umbraco.Tests.AcceptanceTest/cypress/plugins/index.js @@ -18,4 +18,11 @@ module.exports = (on, config) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config + const baseUrl = config.env.baseUrl || null; + + if (baseUrl) { + config.baseUrl = baseUrl; + } + + return config; } diff --git a/src/Umbraco.Tests.AcceptanceTest/package.json b/src/Umbraco.Tests.AcceptanceTest/package.json index 2017142d1e..e845681f18 100644 --- a/src/Umbraco.Tests.AcceptanceTest/package.json +++ b/src/Umbraco.Tests.AcceptanceTest/package.json @@ -1,5 +1,7 @@ { "scripts": { + "postinstall": "node postinstall.js", + "config": "node config.js", "test": "npx cypress run", "ui": "npx cypress open" }, @@ -7,7 +9,8 @@ "cross-env": "^7.0.2", "cypress": "^5.1.0", "ncp": "^2.0.0", - "umbraco-cypress-testhelpers": "^1.0.0-beta-50" + "umbraco-cypress-testhelpers": "^1.0.0-beta-50", + "prompt": "^1.0.0" }, "dependencies": { "typescript": "^3.9.2" diff --git a/src/Umbraco.Tests.AcceptanceTest/postinstall.js b/src/Umbraco.Tests.AcceptanceTest/postinstall.js new file mode 100644 index 0000000000..6117ac84f0 --- /dev/null +++ b/src/Umbraco.Tests.AcceptanceTest/postinstall.js @@ -0,0 +1,14 @@ +const fs = require('fs'); + +const configPath = './cypress.env.json'; + +try { + if (fs.existsSync(configPath)) { + //file exists + console.log("Skips configuration as file already exists, run 'npm run config' to change your configuration."); + } else { + require('./config.js'); + } +} catch(err) { + console.error(err) +} diff --git a/src/Umbraco.Tests/Services/EntityServiceTests.cs b/src/Umbraco.Tests/Services/EntityServiceTests.cs index 0598b8cea2..75f3662ee2 100644 --- a/src/Umbraco.Tests/Services/EntityServiceTests.cs +++ b/src/Umbraco.Tests/Services/EntityServiceTests.cs @@ -8,12 +8,12 @@ using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Services; -using Umbraco.Tests.TestHelpers; using Umbraco.Tests.TestHelpers.Entities; using Umbraco.Tests.Testing; namespace Umbraco.Tests.Services { + /// /// Tests covering the EntityService /// diff --git a/src/Umbraco.Tests/Services/ExternalLoginServiceTests.cs b/src/Umbraco.Tests/Services/ExternalLoginServiceTests.cs new file mode 100644 index 0000000000..8a31518ca0 --- /dev/null +++ b/src/Umbraco.Tests/Services/ExternalLoginServiceTests.cs @@ -0,0 +1,249 @@ +using System; +using System.Linq; +using System.Threading; +using Microsoft.AspNet.Identity; +using NUnit.Framework; +using Umbraco.Core.Models.Identity; +using Umbraco.Core.Models.Membership; +using Umbraco.Core.Persistence.Dtos; +using Umbraco.Tests.TestHelpers; +using Umbraco.Tests.Testing; + +namespace Umbraco.Tests.Services +{ + [TestFixture] + [Apartment(ApartmentState.STA)] + [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] + public class ExternalLoginServiceTests : TestWithDatabaseBase + { + [Test] + public void Removes_Existing_Duplicates_On_Save() + { + var user = new User("Test", "test@test.com", "test", "helloworldtest"); + ServiceContext.UserService.Save(user); + + var providerKey = Guid.NewGuid().ToString("N"); + var latest = DateTime.Now.AddDays(-1); + var oldest = DateTime.Now.AddDays(-10); + + using (var scope = ScopeProvider.CreateScope()) + { + // insert duplicates manuall + scope.Database.Insert(new ExternalLoginDto + { + UserId = user.Id, + LoginProvider = "test1", + ProviderKey = providerKey, + CreateDate = latest + }); + scope.Database.Insert(new ExternalLoginDto + { + UserId = user.Id, + LoginProvider = "test1", + ProviderKey = providerKey, + CreateDate = oldest + }); + } + + // try to save 2 other duplicates + var externalLogins = new[] + { + new ExternalLogin("test2", providerKey), + new ExternalLogin("test2", providerKey), + new ExternalLogin("test1", providerKey) + }; + + ServiceContext.ExternalLoginService.Save(user.Id, externalLogins); + + var logins = ServiceContext.ExternalLoginService.GetAll(user.Id).ToList(); + + // duplicates will be removed, keeping the latest entries + Assert.AreEqual(2, logins.Count); + + var test1 = logins.Single(x => x.LoginProvider == "test1"); + Assert.Greater(test1.CreateDate, latest); + } + + [Test] + public void Does_Not_Persist_Duplicates() + { + var user = new User("Test", "test@test.com", "test", "helloworldtest"); + ServiceContext.UserService.Save(user); + + var providerKey = Guid.NewGuid().ToString("N"); + var externalLogins = new[] + { + new ExternalLogin("test1", providerKey), + new ExternalLogin("test1", providerKey) + }; + + ServiceContext.ExternalLoginService.Save(user.Id, externalLogins); + + var logins = ServiceContext.ExternalLoginService.GetAll(user.Id).ToList(); + Assert.AreEqual(1, logins.Count); + } + + [Test] + public void Single_Create() + { + var user = new User("Test", "test@test.com", "test", "helloworldtest"); + ServiceContext.UserService.Save(user); + + var extLogin = new IdentityUserLogin("test1", Guid.NewGuid().ToString("N"), user.Id) + { + UserData = "hello" + }; + ServiceContext.ExternalLoginService.Save(extLogin); + + var found = ServiceContext.ExternalLoginService.GetAll(user.Id); + + Assert.AreEqual(1, found.Count()); + Assert.IsTrue(extLogin.HasIdentity); + Assert.IsTrue(extLogin.Id > 0); + } + + [Test] + public void Single_Update() + { + var user = new User("Test", "test@test.com", "test", "helloworldtest"); + ServiceContext.UserService.Save(user); + + var extLogin = new IdentityUserLogin("test1", Guid.NewGuid().ToString("N"), user.Id) + { + UserData = "hello" + }; + ServiceContext.ExternalLoginService.Save(extLogin); + + extLogin.UserData = "world"; + ServiceContext.ExternalLoginService.Save(extLogin); + + var found = ServiceContext.ExternalLoginService.GetAll(user.Id).Cast().ToList(); + Assert.AreEqual(1, found.Count); + Assert.AreEqual("world", found[0].UserData); + } + + [Test] + public void Multiple_Update() + { + var user = new User("Test", "test@test.com", "test", "helloworldtest"); + ServiceContext.UserService.Save(user); + + var providerKey1 = Guid.NewGuid().ToString("N"); + var providerKey2 = Guid.NewGuid().ToString("N"); + var extLogins = new[] + { + new ExternalLogin("test1", providerKey1, "hello"), + new ExternalLogin("test2", providerKey2, "world") + }; + ServiceContext.ExternalLoginService.Save(user.Id, extLogins); + + extLogins = new[] + { + new ExternalLogin("test1", providerKey1, "123456"), + new ExternalLogin("test2", providerKey2, "987654") + }; + ServiceContext.ExternalLoginService.Save(user.Id, extLogins); + + var found = ServiceContext.ExternalLoginService.GetAll(user.Id).Cast().OrderBy(x => x.LoginProvider).ToList(); + Assert.AreEqual(2, found.Count); + Assert.AreEqual("123456", found[0].UserData); + Assert.AreEqual("987654", found[1].UserData); + } + + [Test] + public void Can_Find_As_Extended_Type() + { + var user = new User("Test", "test@test.com", "test", "helloworldtest"); + ServiceContext.UserService.Save(user); + + var providerKey1 = Guid.NewGuid().ToString("N"); + var providerKey2 = Guid.NewGuid().ToString("N"); + var extLogins = new[] + { + new ExternalLogin("test1", providerKey1, "hello"), + new ExternalLogin("test2", providerKey2, "world") + }; + ServiceContext.ExternalLoginService.Save(user.Id, extLogins); + + var found = ServiceContext.ExternalLoginService.Find("test2", providerKey2).ToList(); + Assert.AreEqual(1, found.Count); + var asExtended = found.Cast().ToList(); + Assert.AreEqual(1, found.Count); + + } + + [Test] + public void Add_Logins() + { + var user = new User("Test", "test@test.com", "test", "helloworldtest"); + ServiceContext.UserService.Save(user); + + var externalLogins = new[] + { + new ExternalLogin("test1", Guid.NewGuid().ToString("N")), + new ExternalLogin("test2", Guid.NewGuid().ToString("N")) + }; + + ServiceContext.ExternalLoginService.Save(user.Id, externalLogins); + + var logins = ServiceContext.ExternalLoginService.GetAll(user.Id).OrderBy(x => x.LoginProvider).ToList(); + Assert.AreEqual(2, logins.Count); + for (int i = 0; i < logins.Count; i++) + { + Assert.AreEqual(logins[i].ProviderKey, externalLogins[i].ProviderKey); + Assert.AreEqual(logins[i].LoginProvider, externalLogins[i].LoginProvider); + } + } + + [Test] + public void Add_Update_Delete_Logins() + { + var user = new User("Test", "test@test.com", "test", "helloworldtest"); + ServiceContext.UserService.Save(user); + + var externalLogins = new[] + { + new ExternalLogin("test1", Guid.NewGuid().ToString("N")), + new ExternalLogin("test2", Guid.NewGuid().ToString("N")), + new ExternalLogin("test3", Guid.NewGuid().ToString("N")), + new ExternalLogin("test4", Guid.NewGuid().ToString("N")) + }; + + ServiceContext.ExternalLoginService.Save(user.Id, externalLogins); + + var logins = ServiceContext.ExternalLoginService.GetAll(user.Id).OrderBy(x => x.LoginProvider).ToList(); + + logins.RemoveAt(0); // remove the first one + logins.Add(new IdentityUserLogin("test5", Guid.NewGuid().ToString("N"), user.Id)); // add a new one + + // save new list + ServiceContext.ExternalLoginService.Save(user.Id, logins.Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey))); + + var updatedLogins = ServiceContext.ExternalLoginService.GetAll(user.Id).OrderBy(x => x.LoginProvider).ToList(); + Assert.AreEqual(4, updatedLogins.Count); + for (int i = 0; i < updatedLogins.Count; i++) + { + Assert.AreEqual(logins[i].LoginProvider, updatedLogins[i].LoginProvider); + } + } + + [Test] + public void Add_Retrieve_User_Data() + { + var user = new User("Test", "test@test.com", "test", "helloworldtest"); + ServiceContext.UserService.Save(user); + + var externalLogins = new[] + { + new ExternalLogin("test1", Guid.NewGuid().ToString("N"), "hello world") + }; + + ServiceContext.ExternalLoginService.Save(user.Id, externalLogins); + + var logins = ServiceContext.ExternalLoginService.GetAll(user.Id).Cast().ToList(); + + Assert.AreEqual("hello world", logins[0].UserData); + + } + } +} diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index 6270c756a5..e068e6fb70 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -170,6 +170,7 @@ + diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index e0afe1c2a7..37a2de77f6 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -38,7 +38,7 @@ "lazyload-js": "1.0.0", "moment": "2.22.2", "ng-file-upload": "12.2.13", - "nouislider": "14.6.1", + "nouislider": "14.6.2", "npm": "^6.14.7", "signalr": "2.4.0", "spectrum-colorpicker2": "2.0.3", diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js index 43598593f4..ed20203d46 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js @@ -13,7 +13,10 @@ } }); - function UmbLoginController($scope, $location, currentUserResource, formHelper, mediaHelper, umbRequestHelper, Upload, localizationService, userService, externalLoginInfo, resetPasswordCodeInfo, $timeout, authResource, $q, $route) { + function UmbLoginController($scope, $location, currentUserResource, formHelper, + mediaHelper, umbRequestHelper, Upload, localizationService, + userService, externalLoginInfo, externalLoginInfoService, + resetPasswordCodeInfo, $timeout, authResource, $q, $route) { const vm = this; @@ -43,7 +46,15 @@ vm.allowPasswordReset = Umbraco.Sys.ServerVariables.umbracoSettings.canSendRequiredEmail && Umbraco.Sys.ServerVariables.umbracoSettings.allowPasswordReset; vm.errorMsg = ""; vm.externalLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLoginsUrl; - vm.externalLoginProviders = externalLoginInfo.providers; + vm.externalLoginProviders = externalLoginInfoService.getLoginProviders(); + vm.externalLoginProviders.forEach(x => { + x.customView = externalLoginInfoService.getLoginProviderView(x); + // if there are errors set for this specific provider than assign them directly to the model + if (externalLoginInfo.errorProvider === x.authType) { + x.errors = externalLoginInfo.errors; + } + }); + vm.denyLocalLogin = externalLoginInfoService.hasDenyLocalLogin(); vm.externalLoginInfo = externalLoginInfo; vm.resetPasswordCodeInfo = resetPasswordCodeInfo; vm.backgroundImage = Umbraco.Sys.ServerVariables.umbracoSettings.loginBackgroundImage; @@ -62,7 +73,7 @@ vm.setPasswordSubmit = setPasswordSubmit; vm.labels = {}; localizationService.localizeMany([ - vm.usernameIsEmail ? "general_email" : "general_username", + vm.usernameIsEmail ? "general_email" : "general_username", vm.usernameIsEmail ? "placeholders_email" : "placeholders_usernameHint", vm.usernameIsEmail ? "placeholders_emptyEmail" : "placeholders_emptyUsername", "placeholders_emptyPassword"] @@ -72,9 +83,11 @@ vm.labels.usernameError = data[2]; vm.labels.passwordError = data[3]; }); - + vm.twoFactor = {}; + vm.loginSuccess = loginSuccess; + function onInit() { // Check if it is a new user @@ -98,11 +111,11 @@ //localize the text localizationService.localize("errorHandling_errorInPasswordFormat", [ - vm.invitedUserPasswordModel.passwordPolicies.minPasswordLength, - vm.invitedUserPasswordModel.passwordPolicies.minNonAlphaNumericChars - ]).then(function (data) { - vm.invitedUserPasswordModel.passwordPolicyText = data; - }); + vm.invitedUserPasswordModel.passwordPolicies.minPasswordLength, + vm.invitedUserPasswordModel.passwordPolicies.minNonAlphaNumericChars + ]).then(function (data) { + vm.invitedUserPasswordModel.passwordPolicyText = data; + }); }) ]).then(function () { vm.inviteStep = Number(inviteVal); @@ -144,12 +157,12 @@ function getStarted() { $location.search('invite', null); - if(vm.onLogin) { + if (vm.onLogin) { vm.onLogin(); } } - function inviteSavePassword () { + function inviteSavePassword() { if (formHelper.submitForm({ scope: $scope })) { @@ -197,37 +210,41 @@ SetTitle(); } + function loginSuccess() { + vm.loginStates.submitButton = "success"; + userService._retryRequestQueue(true); + if (vm.onLogin) { + vm.onLogin(); + } + } + function loginSubmit() { - - if (formHelper.submitForm({ scope: $scope })) { + + if (formHelper.submitForm({ scope: $scope, formCtrl: vm.loginForm })) { //if the login and password are not empty we need to automatically // validate them - this is because if there are validation errors on the server // then the user has to change both username & password to resubmit which isn't ideal, // so if they're not empty, we'll just make sure to set them to valid. - if (vm.login && vm.password && vm.login.length > 0 && vm.password.length > 0) { + if (vm.login && vm.password && vm.login.length > 0 && vm.password.length > 0) { vm.loginForm.username.$setValidity('auth', true); vm.loginForm.password.$setValidity('auth', true); } - + if (vm.loginForm.$invalid) { SetTitle(); return; } - + // make sure that we are returning to the login view. vm.view = "login"; vm.loginStates.submitButton = "busy"; userService.authenticate(vm.login, vm.password) - .then(function(data) { - vm.loginStates.submitButton = "success"; - userService._retryRequestQueue(true); - if (vm.onLogin) { - vm.onLogin(); - } - }, - function(reason) { + .then(function (data) { + loginSuccess(); + }, + function (reason) { //is Two Factor required? if (reason.status === 402) { @@ -249,13 +266,13 @@ //setup a watch for both of the model values changing, if they change // while the form is invalid, then revalidate them so that the form can // be submitted again. - vm.loginForm.username.$viewChangeListeners.push(function() { + vm.loginForm.username.$viewChangeListeners.push(function () { if (vm.loginForm.$invalid) { vm.loginForm.username.$setValidity('auth', true); vm.loginForm.password.$setValidity('auth', true); } }); - vm.loginForm.password.$viewChangeListeners.push(function() { + vm.loginForm.password.$viewChangeListeners.push(function () { if (vm.loginForm.$invalid) { vm.loginForm.username.$setValidity('auth', true); vm.loginForm.password.$setValidity('auth', true); @@ -460,7 +477,7 @@ case "2fa-login": title = "Two Factor Authentication"; break; - } + } $scope.$emit("$changeTitle", title); } diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js index 5e007a7ff4..a236e7f5ac 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js @@ -86,6 +86,7 @@ Use this directive to render an umbraco button. The directive can be used to gen bindings: { action: "&?", href: "@?", + hrefTarget: "@?", type: "@", buttonStyle: "@?", state: " { + // Do nothing if we get an error. + } + )); }); return $q.all(tasks); @@ -439,7 +443,14 @@ * @return {Array} array of strings representing alias. */ getAvailableAliasesForBlockContent: function () { - return this.blockConfigurations.map(blockConfiguration => this.getScaffoldFromKey(blockConfiguration.contentElementTypeKey).contentTypeAlias); + return this.blockConfigurations.map( + (blockConfiguration) => { + var scaffold = this.getScaffoldFromKey(blockConfiguration.contentElementTypeKey); + if (scaffold) { + return scaffold.contentTypeAlias; + } + } + ); }, /** @@ -519,7 +530,7 @@ var dataModel = getDataByUdi(contentUdi, this.value.contentData); if (dataModel === null) { - console.error("Couldn't find content model of " + contentUdi) + console.error("Couldn't find content data of " + contentUdi) return null; } @@ -591,7 +602,7 @@ var settingsData = getDataByUdi(settingsUdi, this.value.settingsData); if (settingsData === null) { - console.error("Couldnt find content settings data of " + settingsUdi) + console.error("Couldnt find settings data of " + settingsUdi) return null; } diff --git a/src/Umbraco.Web.UI.Client/src/common/services/externallogininfo.service.js b/src/Umbraco.Web.UI.Client/src/common/services/externallogininfo.service.js new file mode 100644 index 0000000000..1d2048b2f5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/services/externallogininfo.service.js @@ -0,0 +1,67 @@ +/** + * @ngdoc service + * @name umbraco.services.externalLoginInfoService + * @description A service for working with external login providers + **/ +function externalLoginInfoService(externalLoginInfo, umbRequestHelper) { + + function getLoginProvider(provider) { + if (provider) { + var found = _.find(externalLoginInfo.providers, x => x.authType == provider); + return found; + } + return null; + } + + function getLoginProviderView(provider) { + if (provider && provider.properties.UmbracoBackOfficeExternalLoginOptions && provider.properties.UmbracoBackOfficeExternalLoginOptions.CustomBackOfficeView) { + return umbRequestHelper.convertVirtualToAbsolutePath(provider.properties.UmbracoBackOfficeExternalLoginOptions.CustomBackOfficeView); + } + return null; + } + + /** + * Returns true if any provider denies local login if `provider` is null, else whether the passed + * @param {any} provider + */ + function hasDenyLocalLogin(provider) { + if (!provider) { + return _.some(externalLoginInfo.providers, x => x.properties.UmbracoBackOfficeExternalLoginOptions.DenyLocalLogin === true); + } + else { + return provider.properties.UmbracoBackOfficeExternalLoginOptions.DenyLocalLogin; + } + } + + /** + * Returns all login providers + */ + function getLoginProviders() { + return externalLoginInfo.providers; + } + + /** Returns all logins providers that have options that the user can interact with */ + function getLoginProvidersWithOptions() { + // only include providers that allow manual linking or ones that provide a custom view + var providers = _.filter(externalLoginInfo.providers, x => { + // transform the data and also include the custom view as a nicer property + x.customView = getLoginProviderView(x); + if (x.customView) { + return true; + } + else { + return x.properties.ExternalSignInAutoLinkOptions.AllowManualLinking; + } + }); + return providers; + } + + return { + hasDenyLocalLogin: hasDenyLocalLogin, + getLoginProvider: getLoginProvider, + getLoginProviders: getLoginProviders, + getLoginProvidersWithOptions: getLoginProvidersWithOptions, + getLoginProviderView: getLoginProviderView + }; +} +angular.module('umbraco.services').factory('externalLoginInfoService', externalLoginInfoService); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/focuslock.service.js b/src/Umbraco.Web.UI.Client/src/common/services/focuslock.service.js index a3dd91194e..9ce2f41691 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/focuslock.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/focuslock.service.js @@ -5,11 +5,15 @@ var elementToInert = document.querySelector('#mainwrapper'); function addInertAttribute() { - elementToInert.setAttribute('inert', true); + if (elementToInert) { + elementToInert.setAttribute('inert', true); + } } function removeInertAttribute() { - elementToInert.removeAttribute('inert'); + if (elementToInert) { + elementToInert.removeAttribute('inert'); + } } var service = { diff --git a/src/Umbraco.Web.UI.Client/src/common/services/iconhelper.service.js b/src/Umbraco.Web.UI.Client/src/common/services/iconhelper.service.js index 5034de67eb..f26763bd14 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/iconhelper.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/iconhelper.service.js @@ -252,7 +252,7 @@ function iconHelper($http, $q, $sce, $timeout, umbRequestHelper) { /** LEGACY - Return a list of icons from icon fonts, optionally filter them */ /** It fetches them directly from the active stylesheets in the browser */ - getLegacyIcons: function(){ + getIcons: function(){ var deferred = $q.defer(); $timeout(function(){ if(collectedIcons){ @@ -284,13 +284,8 @@ function iconHelper($http, $q, $sce, $timeout, umbRequestHelper) { s = s.substring(0, hasPseudo); } - var icon = { - name: s, - svgString: undefined - }; - - if(collectedIcons.indexOf(icon) < 0 && s !== "icon-chevron-up" && s !== "icon-chevron-down"){ - collectedIcons.push(icon); + if(collectedIcons.indexOf(s) < 0){ + collectedIcons.push(s); } } } diff --git a/src/Umbraco.Web.UI.Client/src/common/services/servervalidationmgr.service.js b/src/Umbraco.Web.UI.Client/src/common/services/servervalidationmgr.service.js index 731cc5ed36..8df5a9ce8c 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/servervalidationmgr.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/servervalidationmgr.service.js @@ -464,7 +464,7 @@ function serverValidationManager($timeout) { * @ngdoc function * @name addErrorsForModelState * @methodOf umbraco.services.serverValidationManager - * @param {any} modelState + * @param {any} modelState the modelState object * @param {any} parentValidationPath optional parameter specifying a nested element's UDI for which this property belongs (for complex editors) * @description * This wires up all of the server validation model state so that valServer and valServerField directives work diff --git a/src/Umbraco.Web.UI.Client/src/common/services/user.service.js b/src/Umbraco.Web.UI.Client/src/common/services/user.service.js index de6fbaf782..00871caab1 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/user.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/user.service.js @@ -1,5 +1,5 @@ angular.module('umbraco.services') - .factory('userService', function ($rootScope, eventsService, $q, $location, requestRetryQueue, authResource, emailMarketingResource, $timeout, angularHelper) { + .factory('userService', function ($rootScope, eventsService, $q, $location, $window, requestRetryQueue, authResource, emailMarketingResource, $timeout, angularHelper) { var currentUser = null; var lastUserId = null; @@ -166,7 +166,7 @@ angular.module('umbraco.services') }, /** Internal method to retry all request after sucessfull login */ - _retryRequestQueue: function(success) { + _retryRequestQueue: function (success) { retryRequestQueue(success) }, @@ -185,18 +185,22 @@ angular.module('umbraco.services') authenticate: function (login, password) { return authResource.performLogin(login, password) - .then(function(data) { + .then(function (data) { // Check if user has a start node set. - if(data.startContentIds.length === 0 && data.startMediaIds.length === 0){ + if (data.startContentIds.length === 0 && data.startMediaIds.length === 0) { var errorMsg = "User has no start-nodes"; var result = { errorMsg: errorMsg, user: data, authenticated: false, lastUserId: lastUserId, loginType: "credentials" }; eventsService.emit("app.notAuthenticated", result); + // TODO: How does this make sense? How can you throw from a promise? Does this get caught by the rejection? + // If so then return $q.reject should be used. throw result; } - + return data; - + + }, function (err) { + return $q.reject(err); }).then(this.setAuthenticationSuccessful); }, setAuthenticationSuccessful: function (data) { @@ -218,8 +222,14 @@ angular.module('umbraco.services') return authResource.performLogout() .then(function (data) { userAuthExpired(); - //done! - return null; + + if (data && data.signOutRedirectUrl) { + $window.location.replace(data.signOutRedirectUrl); + } + else { + //done! + return null; + } }); }, @@ -235,9 +245,9 @@ angular.module('umbraco.services') setCurrentUser(data); deferred.resolve(currentUser); - }, function () { + }, function (err) { //it failed, so they are not logged in - deferred.reject(); + deferred.reject(err); }); return deferred.promise; @@ -245,7 +255,7 @@ angular.module('umbraco.services') /** Returns the current user object in a promise */ getCurrentUser: function (args) { - + if (!currentUser) { return authResource.getCurrentUser() .then(function (data) { @@ -260,9 +270,9 @@ angular.module('umbraco.services') setCurrentUser(data); return $q.when(currentUser); - }, function () { + }, function (err) { //it failed, so they are not logged in - return $q.reject(currentUser); + return $q.reject(err); }); } diff --git a/src/Umbraco.Web.UI.Client/src/less/belle.less b/src/Umbraco.Web.UI.Client/src/less/belle.less index ff834de837..e5ce8d572c 100644 --- a/src/Umbraco.Web.UI.Client/src/less/belle.less +++ b/src/Umbraco.Web.UI.Client/src/less/belle.less @@ -205,6 +205,7 @@ // Property Editors @import "../views/components/blockcard/umb-block-card-grid.less"; @import "../views/components/blockcard/umb-block-card.less"; +@import "../views/components/umb-property-info-button/umb-property-info-button.less"; @import "../views/propertyeditors/blocklist/umb-block-list-property-editor.less"; @import "../views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.less"; @import "../views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.less"; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-ellipsis.less b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-ellipsis.less index 54302ba869..7104e6478f 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-ellipsis.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-ellipsis.less @@ -4,10 +4,10 @@ margin: 0 auto; cursor: pointer; border-radius: @baseBorderRadius; - color: black; + color: @ui-action-discreet-type; position: relative; opacity: 0.8; - transition: opacity .3s ease-out; + transition: opacity 120ms, color 120ms; &--absolute { position: absolute; @@ -23,6 +23,10 @@ justify-content: center; } + &:hover { + color: @ui-action-discreet-type-hover; + } + .umb-button-ellipsis--tab, .umb-tour-is-visible .umb-tree &, &:hover, @@ -47,6 +51,7 @@ &__icon { color: inherit; flex-basis: 100%; + font-size: 12px; .umb-button-ellipsis--tab & { margin: 0 0 7px; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/card.less b/src/Umbraco.Web.UI.Client/src/less/components/card.less index 017468fa0c..a1a4b4bc5e 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/card.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/card.less @@ -159,6 +159,7 @@ justify-content: center; flex-direction: column; background-color: transparent; + word-break: break-word; } } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree-root.less b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree-root.less index a3529d5504..ba33883b08 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree-root.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree-root.less @@ -1,5 +1,7 @@ -.umb-tree-root { +.umb-tree-root { + border: 2px solid transparent; + &-link { display: flex; align-items: center; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less index d4599ea03d..f967994c0f 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less @@ -95,7 +95,6 @@ body.touch .umb-tree { position: relative; width: auto; height: auto; - margin: 0 5px 0 auto; overflow: visible; clip: auto; } @@ -169,7 +168,9 @@ body.touch .umb-tree { .umb-tree .umb-tree-node-checked > .umb-tree-item__inner > i[class^="icon-"], .umb-tree .umb-tree-node-checked > .umb-tree-item__inner > i[class*=" icon-"], .umb-tree .umb-tree-node-checked .umb-search-group-item-name > i[class^="icon-"], -.umb-tree .umb-tree-node-checked .umb-search-group-item-name > i[class*=" icon-"] { +.umb-tree .umb-tree-node-checked .umb-search-group-item-name > i[class*=" icon-"], +.umb-tree .umb-tree-node-checked > i[class^="icon-"], +.umb-tree .umb-tree-node-checked > i[class*="icon-"] { font-family: 'icomoon' !important; color: @green !important; @@ -185,9 +186,10 @@ body.touch .umb-tree { flex: 0 0 auto; justify-content: flex-end; text-align: center; - margin: 0 5px 0 auto; + margin: 0 10px 0 auto; cursor: pointer; border-radius: @baseBorderRadius; + transition: background-color 120ms; .umb-button-ellipsis { padding: 3px 5px; @@ -207,7 +209,7 @@ body.touch .umb-tree { } &:hover { - background: rgba(255, 255, 255, .5); + background-color: rgba(255, 255, 255, .8); i { background: @ui-active-type-hover; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less index cff9980483..9567840049 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less @@ -97,7 +97,8 @@ height: 20px; width: 20px; position: absolute; - top: -1px; + top: 0; + } &__check { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-icon.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-icon.less index e3b0550d15..e08174e378 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-icon.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-icon.less @@ -1,4 +1,27 @@ .umb-icon { + display: inline-block; + width: 1em; + height: 1em; + + svg { + width: 100%; + height: 100%; + fill: currentColor; + } + + &.large{ + width: 32px; + height: 32px; + } + &.medium{ + width: 24px; + height: 24px; + } + &.small{ + width: 14px; + height: 14px; + } + &:before, &:after { content: none !important; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less index e23325a7d2..5062aae660 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less @@ -58,7 +58,6 @@ } .umb-iconpicker-item i { - font-family: inherit; font-size: 30px; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-media-grid.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-media-grid.less index 09fea977c7..d05139a06f 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-media-grid.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-media-grid.less @@ -234,3 +234,7 @@ .umb-media-grid__list-view .umb-table-cell.umb-table__name .item-name { white-space:normal; } +.umb-media-grid__list-view .umb-table-cell.umb-table__name ins { + text-decoration: none; + margin-top: 3px; +} diff --git a/src/Umbraco.Web.UI.Client/src/less/helveticons.less b/src/Umbraco.Web.UI.Client/src/less/helveticons.less index a51f4e9f6f..cded6b8269 100644 --- a/src/Umbraco.Web.UI.Client/src/less/helveticons.less +++ b/src/Umbraco.Web.UI.Client/src/less/helveticons.less @@ -8,36 +8,9 @@ font-style: normal; } -span[class^="icon-"], -span[class*=" icon-"] { - display: inline-block; - width: 1em; - height: 1em; - *margin-right: .3em; - - svg { - width: 100%; - height: 100%; - fill: currentColor; - } - - &.large{ - width: 32px; - height: 32px; - } - &.medium{ - width: 24px; - height: 24px; - } - &.small{ - width: 14px; - height: 14px; - } -} [class^="icon-"], -[class*=" icon-"], -button.icon-trash { +[class*=" icon-"]{ font-family: 'icomoon'; speak: none; font-style: normal; @@ -52,8 +25,7 @@ button.icon-trash { } [class^="icon-"]:before, -[class*=" icon-"]:before, -button.icon-trash { +[class*=" icon-"]:before { text-decoration: inherit; display: inline-block; speak: none; @@ -69,1838 +41,1834 @@ i.small{ font-size: 14px; } -i.icon-zoom-out:before { +.icon-zoom-out:before { content: "\e000"; } -i.icon-truck:before { +.icon-truck:before { content: "\e001"; } -i.icon-zoom-in:before { +.icon-zoom-in:before { content: "\e002"; } -i.icon-zip:before { +.icon-zip:before { content: "\e003"; } -i.icon-axis-rotation:before { +.icon-axis-rotation:before { content: "\e004"; } -i.icon-yen-bag:before { +.icon-yen-bag:before { content: "\e005"; } -i.icon-axis-rotation-2:before { +.icon-axis-rotation-2:before { content: "\e006"; } -i.icon-axis-rotation-3:before { +.icon-axis-rotation-3:before { content: "\e007"; } -i.icon-wrench:before { +.icon-wrench:before { content: "\e008"; } -i.icon-wine-glass:before { +.icon-wine-glass:before { content: "\e009"; } -i.icon-wrong:before { +.icon-wrong:before { content: "\e00a"; } -i.icon-windows:before { +.icon-windows:before { content: "\e00b"; } -i.icon-window-sizes:before { +.icon-window-sizes:before { content: "\e00c"; } -i.icon-window-popin:before { +.icon-window-popin:before { content: "\e00d"; } -i.icon-wifi:before { +.icon-wifi:before { content: "\e00e"; } -i.icon-width:before { +.icon-width:before { content: "\e00f"; } -i.icon-weight:before { +.icon-weight:before { content: "\e010"; } -i.icon-war:before { +.icon-war:before { content: "\e011"; } -i.icon-wand:before { +.icon-wand:before { content: "\e012"; } -i.icon-wallet:before { +.icon-wallet:before { content: "\e013"; } -i.icon-wall-plug:before { +.icon-wall-plug:before { content: "\e014"; } -i.icon-voice:before { +.icon-voice:before { content: "\e016"; } -i.icon-video:before { +.icon-video:before { content: "\e017"; } -i.icon-vcard:before { +.icon-vcard:before { content: "\e018"; } -i.icon-utilities:before { +.icon-utilities:before { content: "\e019"; } -i.icon-users:before { +.icon-users:before { content: "\e01a"; } -i.icon-users-alt:before { +.icon-users-alt:before { content: "\e01b"; } -i.icon-user:before { +.icon-user:before { content: "\e01c"; } -i.icon-user-glasses:before { +.icon-user-glasses:before { content: "\e01d"; } -i.icon-user-females:before { +.icon-user-females:before { content: "\e01e"; } -i.icon-user-females-alt:before { +.icon-user-females-alt:before { content: "\e01f"; } -i.icon-user-female:before { +.icon-user-female:before { content: "\e020"; } -i.icon-usb:before { +.icon-usb:before { content: "\e021"; } -i.icon-usb-connector:before { +.icon-usb-connector:before { content: "\e022"; } -i.icon-unlocked:before { +.icon-unlocked:before { content: "\e023"; } -i.icon-universal:before { +.icon-universal:before { content: "\e024"; } -i.icon-undo:before { +.icon-undo:before { content: "\e025"; } -i.icon-umbrella:before { +.icon-umbrella:before { content: "\e026"; } -i.icon-umb-deploy:before { +.icon-umb-deploy:before { content: "\e027"; } -i.icon-umb-contour:before, .traycontour:before { +.icon-umb-contour:before, .traycontour:before { content: "\e028"; } -i.icon-umb-settings:before, .traysettings:before { +.icon-umb-settings:before, .traysettings:before { content: "\e029"; } -i.icon-umb-users:before, .trayuser:before, .trayusers:before{ +.icon-umb-users:before, .trayuser:before, .trayusers:before{ content: "\e02a"; } -i.icon-umb-media:before, .traymedia:before { +.icon-umb-media:before, .traymedia:before { content: "\e02b"; } -i.icon-umb-content:before, .traycontent:before{ +.icon-umb-content:before, .traycontent:before{ content: "\e02c"; } -i.icon-umb-developer:before, .traydeveloper:before { +.icon-umb-developer:before, .traydeveloper:before { content: "\e02d"; } -i.icon-umb-members:before, .traymember:before { +.icon-umb-members:before, .traymember:before { content: "\e015"; } -i.icon-umb-translation:before, .traytranslation:before { +.icon-umb-translation:before, .traytranslation:before { content: "\e1fd"; } -i.icon-tv:before { +.icon-tv:before { content: "\e02e"; } -i.icon-tv-old:before { +.icon-tv-old:before { content: "\e02f"; } -i.icon-trophy:before { +.icon-trophy:before { content: "\e030"; } -i.icon-tree:before { +.icon-tree:before { content: "\e031"; } -i.icon-trash:before, -button.icon-trash:before { +.icon-trash:before { content: "\e032"; } -i.icon-trash-alt:before, -button.icon-trash-alt:before { +.icon-trash-alt:before { content: "\e033"; } -i.icon-trash-alt-2:before, -button.icon-trash-alt-2:before { +.icon-trash-alt-2:before { content: "\e034"; } -i.icon-train:before { +.icon-train:before { content: "\e035"; } -i.icon-trafic:before, -i.icon-traffic:before { +.icon-trafic:before, +.icon-traffic:before { content: "\e036"; } -i.icon-traffic-alt:before { +.icon-traffic-alt:before { content: "\e037"; } -i.icon-top:before { +.icon-top:before { content: "\e038"; } -i.icon-tools:before { +.icon-tools:before { content: "\e039"; } -i.icon-timer:before { +.icon-timer:before { content: "\e03a"; } -i.icon-time:before { +.icon-time:before { content: "\e03b"; } -i.icon-t-shirt:before { +.icon-t-shirt:before { content: "\e03c"; } -i.icon-tab-key:before { +.icon-tab-key:before { content: "\e03d"; } -i.icon-tab:before { +.icon-tab:before { content: "\e03e"; } -i.icon-tactics:before { +.icon-tactics:before { content: "\e03f"; } -i.icon-tag:before { +.icon-tag:before { content: "\e040"; } -i.icon-tags:before { +.icon-tags:before { content: "\e041"; } -i.icon-takeaway-cup:before { +.icon-takeaway-cup:before { content: "\e042"; } -i.icon-target:before { +.icon-target:before { content: "\e043"; } -i.icon-temperature-alt:before, -i.icon-temperatrure-alt:before { +.icon-temperature-alt:before, +.icon-temperatrure-alt:before { content: "\e044"; } -i.icon-temperature:before { +.icon-temperature:before { content: "\e045"; } -i.icon-terminal:before { +.icon-terminal:before { content: "\e046"; } -i.icon-theater:before { +.icon-theater:before { content: "\e047"; } -i.icon-thief:before, -i.icon-theif:before { +.icon-thief:before, +.icon-theif:before { content: "\e048"; } -i.icon-thought-bubble:before { +.icon-thought-bubble:before { content: "\e049"; } -i.icon-thumb-down:before { +.icon-thumb-down:before { content: "\e04a"; } -i.icon-thumb-up:before { +.icon-thumb-up:before { content: "\e04b"; } -i.icon-thumbnail-list:before { +.icon-thumbnail-list:before { content: "\e04c"; } -i.icon-thumbnails-small:before { +.icon-thumbnails-small:before { content: "\e04d"; } -i.icon-thumbnails:before { +.icon-thumbnails:before { content: "\e04e"; } -i.icon-ticket:before { +.icon-ticket:before { content: "\e04f"; } -i.icon-sync:before { +.icon-sync:before { content: "\e050"; } -i.icon-sweatshirt:before { +.icon-sweatshirt:before { content: "\e051"; } -i.icon-sunny:before { +.icon-sunny:before { content: "\e052"; } -i.icon-stream:before { +.icon-stream:before { content: "\e053"; } -i.icon-store:before { +.icon-store:before { content: "\e054"; } -i.icon-stop:before { +.icon-stop:before { content: "\e055"; } -i.icon-stop-hand:before { +.icon-stop-hand:before { content: "\e056"; } -i.icon-stop-alt:before { +.icon-stop-alt:before { content: "\e057"; } -i.icon-stamp:before { +.icon-stamp:before { content: "\e058"; } -i.icon-stacked-disks:before { +.icon-stacked-disks:before { content: "\e059"; } -i.icon-ssd:before { +.icon-ssd:before { content: "\e05a"; } -i.icon-squiggly-line:before { +.icon-squiggly-line:before { content: "\e05b"; } -i.icon-sprout:before { +.icon-sprout:before { content: "\e05c"; } -i.icon-split:before { +.icon-split:before { content: "\e05d"; } -i.icon-split-alt:before { +.icon-split-alt:before { content: "\e05e"; } -i.icon-speed-gauge:before { +.icon-speed-gauge:before { content: "\e05f"; } -i.icon-speaker:before { +.icon-speaker:before { content: "\e060"; } -i.icon-sound:before { +.icon-sound:before { content: "\e061"; } -i.icon-spades:before { +.icon-spades:before { content: "\e062"; } -i.icon-sound-waves:before { +.icon-sound-waves:before { content: "\e063"; } -i.icon-shipping-box:before { +.icon-shipping-box:before { content: "\e064"; } -i.icon-shipping:before { +.icon-shipping:before { content: "\e065"; } -i.icon-shoe:before { +.icon-shoe:before { content: "\e066"; } -i.icon-shopping-basket-alt-2:before { +.icon-shopping-basket-alt-2:before { content: "\e067"; } -i.icon-shopping-basket:before { +.icon-shopping-basket:before { content: "\e068"; } -i.icon-shopping-basket-alt:before { +.icon-shopping-basket-alt:before { content: "\e069"; } -i.icon-shorts:before { +.icon-shorts:before { content: "\e06a"; } -i.icon-shuffle:before { +.icon-shuffle:before { content: "\e06b"; } -i.icon-science:before, -i.icon-sience:before { +.icon-science:before, +.icon-sience:before { content: "\e06c"; } -i.icon-simcard:before { +.icon-simcard:before { content: "\e06d"; } -i.icon-single-note:before { +.icon-single-note:before { content: "\e06e"; } -i.icon-sitemap:before { +.icon-sitemap:before { content: "\e06f"; } -i.icon-sleep:before { +.icon-sleep:before { content: "\e070"; } -i.icon-slideshow:before { +.icon-slideshow:before { content: "\e071"; } -i.icon-smiley-inverted:before { +.icon-smiley-inverted:before { content: "\e072"; } -i.icon-smiley:before { +.icon-smiley:before { content: "\e073"; } -i.icon-snow:before { +.icon-snow:before { content: "\e074"; } -i.icon-sound-low:before { +.icon-sound-low:before { content: "\e075"; } -i.icon-sound-medium:before { +.icon-sound-medium:before { content: "\e076"; } -i.icon-sound-off:before { +.icon-sound-off:before { content: "\e077"; } -i.icon-shift:before { +.icon-shift:before { content: "\e078"; } -i.icon-shield:before { +.icon-shield:before { content: "\e079"; } -i.icon-sharing-iphone:before { +.icon-sharing-iphone:before { content: "\e07a"; } -i.icon-share:before { +.icon-share:before { content: "\e07b"; } -i.icon-share-alt:before { +.icon-share-alt:before { content: "\e07c"; } -i.icon-share-alt-2:before { +.icon-share-alt-2:before { content: "\e07d"; } -i.icon-settings:before, -button.icon-settings:before { +.icon-settings:before { content: "\e07e"; } -i.icon-settings-alt:before { +.icon-settings-alt:before { content: "\e07f"; } -i.icon-settings-alt-2:before { +.icon-settings-alt-2:before { content: "\e080"; } -i.icon-server:before { +.icon-server:before { content: "\e081"; } -i.icon-server-alt:before { +.icon-server-alt:before { content: "\e082"; } -i.icon-sensor:before { +.icon-sensor:before { content: "\e083"; } -i.icon-security-camera:before { +.icon-security-camera:before { content: "\e084"; } -i.icon-search:before { +.icon-search:before { content: "\e085"; } -i.icon-scull:before { +.icon-scull:before { content: "\e086"; } -i.icon-script:before { +.icon-script:before { content: "\e087"; } -i.icon-script-alt:before { +.icon-script-alt:before { content: "\e088"; } -i.icon-screensharing:before { +.icon-screensharing:before { content: "\e089"; } -i.icon-school:before { +.icon-school:before { content: "\e08a"; } -i.icon-scan:before { +.icon-scan:before { content: "\e08b"; } -i.icon-refresh:before { +.icon-refresh:before { content: "\e08c"; } -i.icon-remote:before { +.icon-remote:before { content: "\e08d"; } -i.icon-remove:before { +.icon-remove:before { content: "\e08e"; } -i.icon-repeat-one:before { +.icon-repeat-one:before { content: "\e08f"; } -i.icon-repeat:before { +.icon-repeat:before { content: "\e090"; } -i.icon-resize:before { +.icon-resize:before { content: "\e091"; } -i.icon-reply-arrow:before { +.icon-reply-arrow:before { content: "\e092"; } -i.icon-return-to-top:before { +.icon-return-to-top:before { content: "\e093"; } -i.icon-right-double-arrow:before { +.icon-right-double-arrow:before { content: "\e094"; } -i.icon-road:before { +.icon-road:before { content: "\e095"; } -i.icon-roadsign:before { +.icon-roadsign:before { content: "\e096"; } -i.icon-rocket:before { +.icon-rocket:before { content: "\e097"; } -i.icon-rss:before { +.icon-rss:before { content: "\e098"; } -i.icon-ruler-alt:before { +.icon-ruler-alt:before { content: "\e099"; } -i.icon-ruler:before { +.icon-ruler:before { content: "\e09a"; } -i.icon-sandbox-toys:before { +.icon-sandbox-toys:before { content: "\e09b"; } -i.icon-satellite-dish:before { +.icon-satellite-dish:before { content: "\e09c"; } -i.icon-save:before { +.icon-save:before { content: "\e09d"; } -i.icon-safedial:before { +.icon-safedial:before { content: "\e09e"; } -i.icon-safe:before { +.icon-safe:before { content: "\e09f"; } -i.icon-redo:before { +.icon-redo:before { content: "\e0a0"; } -i.icon-printer-alt:before { +.icon-printer-alt:before { content: "\e0a1"; } -i.icon-planet:before { +.icon-planet:before { content: "\e0a2"; } -i.icon-paste-in:before { +.icon-paste-in:before { content: "\e0a3"; } -i.icon-os-x:before { +.icon-os-x:before { content: "\e0a4"; } -i.icon-navigation-left:before { +.icon-navigation-left:before { content: "\e0a5"; } -i.icon-message:before { +.icon-message:before { content: "\e0a6"; } -i.icon-lock:before { +.icon-lock:before { content: "\e0a7"; } -i.icon-layers-alt:before { +.icon-layers-alt:before { content: "\e0a8"; } -i.icon-record:before { +.icon-record:before { content: "\e0a9"; } -i.icon-print:before { +.icon-print:before { content: "\e0aa"; } -i.icon-plane:before { +.icon-plane:before { content: "\e0ab"; } -i.icon-partly-cloudy:before { +.icon-partly-cloudy:before { content: "\e0ac"; } -i.icon-ordered-list:before { +.icon-ordered-list:before { content: "\e0ad"; } -i.icon-navigation-last:before { +.icon-navigation-last:before { content: "\e0ae"; } -i.icon-message-unopened:before { +.icon-message-unopened:before { content: "\e0af"; } -i.icon-location-nearby:before { +.icon-location-nearby:before { content: "\e0b0"; } -i.icon-laptop:before { +.icon-laptop:before { content: "\e0b1"; } -i.icon-reception:before { +.icon-reception:before { content: "\e0b2"; } -i.icon-price-yen:before { +.icon-price-yen:before { content: "\e0b3"; } -i.icon-piracy:before { +.icon-piracy:before { content: "\e0b4"; } -i.icon-parental-control:before { +.icon-parental-control:before { content: "\e0b5"; } -i.icon-operator:before { +.icon-operator:before { content: "\e0b6"; } -i.icon-navigation-horizontal:before { +.icon-navigation-horizontal:before { content: "\e0b7"; } -i.icon-message-open:before { +.icon-message-open:before { content: "\e0b8"; } -i.icon-lab:before { +.icon-lab:before { content: "\e0b9"; } -i.icon-location-near-me:before { +.icon-location-near-me:before { content: "\e0ba"; } -i.icon-receipt-yen:before { +.icon-receipt-yen:before { content: "\e0bb"; } -i.icon-price-pound:before { +.icon-price-pound:before { content: "\e0bc"; } -i.icon-pin-location:before { +.icon-pin-location:before { content: "\e0bd"; } -i.icon-parachute-drop:before { +.icon-parachute-drop:before { content: "\e0be"; } -i.icon-old-phone:before { +.icon-old-phone:before { content: "\e0bf"; } -i.icon-merge:before { +.icon-merge:before { content: "\e0c0"; } -i.icon-navigation-first:before { +.icon-navigation-first:before { content: "\e0c1"; } -i.icon-locate:before { +.icon-locate:before { content: "\e0c2"; } -i.icon-keyhole:before { +.icon-keyhole:before { content: "\e0c3"; } -i.icon-receipt-pound:before { +.icon-receipt-pound:before { content: "\e0c4"; } -i.icon-price-euro:before { +.icon-price-euro:before { content: "\e0c5"; } -i.icon-piggy-bank:before { +.icon-piggy-bank:before { content: "\e0c6"; } -i.icon-paper-plane:before { +.icon-paper-plane:before { content: "\e0c7"; } -i.icon-old-key:before { +.icon-old-key:before { content: "\e0c8"; } -i.icon-navigation-down:before { +.icon-navigation-down:before { content: "\e0c9"; } -i.icon-megaphone:before { +.icon-megaphone:before { content: "\e0ca"; } -i.icon-loading:before { +.icon-loading:before { content: "\e0cb"; } -i.icon-keychain:before { +.icon-keychain:before { content: "\e0cc"; } -i.icon-receipt-euro:before { +.icon-receipt-euro:before { content: "\e0cd"; } -i.icon-price-dollar:before { +.icon-price-dollar:before { content: "\e0ce"; } -i.icon-pie-chart:before { +.icon-pie-chart:before { content: "\e0cf"; } -i.icon-paper-plane-alt:before { +.icon-paper-plane-alt:before { content: "\e0d0"; } -i.icon-notepad:before { +.icon-notepad:before { content: "\e0d1"; } -i.icon-navigation-bottom:before { +.icon-navigation-bottom:before { content: "\e0d2"; } -i.icon-meeting:before { +.icon-meeting:before { content: "\e0d3"; } -i.icon-keyboard:before { +.icon-keyboard:before { content: "\e0d4"; } -i.icon-load:before { +.icon-load:before { content: "\e0d5"; } -i.icon-receipt-dollar:before { +.icon-receipt-dollar:before { content: "\e0d6"; } -i.icon-previous:before { +.icon-previous:before { content: "\e0d7"; } -i.icon-pictures:before { +.icon-pictures:before { content: "\e0d8"; } -i.icon-notepad-alt:before { +.icon-notepad-alt:before { content: "\e0d9"; } -i.icon-paper-bag:before { +.icon-paper-bag:before { content: "\e0da"; } -i.icon-badge:before { +.icon-badge:before { content: "\e0db"; } -i.icon-medicine:before { +.icon-medicine:before { content: "\e0dc"; } -i.icon-list:before { +.icon-list:before { content: "\e0dd"; } -i.icon-key:before { +.icon-key:before { content: "\e0de"; } -i.icon-receipt-alt:before { +.icon-receipt-alt:before { content: "\e0df"; } -i.icon-previous-media:before { +.icon-previous-media:before { content: "\e0e0"; } -i.icon-pictures-alt:before { +.icon-pictures-alt:before { content: "\e0e1"; } -i.icon-pants:before { +.icon-pants:before { content: "\e0e2"; } -i.icon-nodes:before { +.icon-nodes:before { content: "\e0e3"; } -i.icon-music:before { +.icon-music:before { content: "\e0e4"; } -i.icon-readonly:before { +.icon-readonly:before { content: "\e0e5"; } -i.icon-presentation:before { +.icon-presentation:before { content: "\e0e6"; } -i.icon-pictures-alt-2:before { +.icon-pictures-alt-2:before { content: "\e0e7"; } -i.icon-panel-close:before, -i.icon-pannel-close:before { +.icon-panel-close:before, +.icon-pannel-close:before { content: "\e0e8"; } -i.icon-next:before { +.icon-next:before { content: "\e0e9"; } -i.icon-multiple-windows:before { +.icon-multiple-windows:before { content: "\e0ea"; } -i.icon-medical-emergency:before { +.icon-medical-emergency:before { content: "\e0eb"; } -i.icon-medal:before { +.icon-medal:before { content: "\e0ec"; } -i.icon-link:before { +.icon-link:before { content: "\e0ed"; } -i.icon-linux-tux:before { +.icon-linux-tux:before { content: "\e0ee"; } -i.icon-junk:before { +.icon-junk:before { content: "\e0ef"; } -i.icon-item-arrangement:before { +.icon-item-arrangement:before { content: "\e0f0"; } -i.icon-iphone:before { +.icon-iphone:before { content: "\e0f1"; } -i.icon-lightning:before { +.icon-lightning:before { content: "\e0f2"; } -i.icon-map:before { +.icon-map:before { content: "\e0f3"; } -i.icon-multiple-credit-cards:before { +.icon-multiple-credit-cards:before { content: "\e0f4"; } -i.icon-next-media:before { +.icon-next-media:before { content: "\e0f5"; } -i.icon-panel-show:before { +.icon-panel-show:before { content: "\e0f6"; } -i.icon-picture:before { +.icon-picture:before { content: "\e0f7"; } -i.icon-power:before { +.icon-power:before { content: "\e0f8"; } -i.icon-re-post:before { +.icon-re-post:before { content: "\e0f9"; } -i.icon-rate:before { +.icon-rate:before { content: "\e0fa"; } -i.icon-rain:before { +.icon-rain:before { content: "\e0fb"; } -i.icon-radio:before { +.icon-radio:before { content: "\e0fc"; } -i.icon-radio-receiver:before { +.icon-radio-receiver:before { content: "\e0fd"; } -i.icon-radio-alt:before { +.icon-radio-alt:before { content: "\e0fe"; } -i.icon-quote:before { +.icon-quote:before { content: "\e0ff"; } -i.icon-qr-code:before { +.icon-qr-code:before { content: "\e100"; } -i.icon-pushpin:before { +.icon-pushpin:before { content: "\e101"; } -i.icon-pulse:before { +.icon-pulse:before { content: "\e102"; } -i.icon-projector:before { +.icon-projector:before { content: "\e103"; } -i.icon-play:before { +.icon-play:before { content: "\e104"; } -i.icon-playing-cards:before { +.icon-playing-cards:before { content: "\e105"; } -i.icon-playlist:before { +.icon-playlist:before { content: "\e106"; } -i.icon-plugin:before { +.icon-plugin:before { content: "\e107"; } -i.icon-podcast:before { +.icon-podcast:before { content: "\e108"; } -i.icon-poker-chip:before { +.icon-poker-chip:before { content: "\e109"; } -i.icon-poll:before { +.icon-poll:before { content: "\e10a"; } -i.icon-post-it:before { +.icon-post-it:before { content: "\e10b"; } -i.icon-pound-bag:before { +.icon-pound-bag:before { content: "\e10c"; } -i.icon-power-outlet:before { +.icon-power-outlet:before { content: "\e10d"; } -i.icon-photo-album:before { +.icon-photo-album:before { content: "\e10e"; } -i.icon-phone:before { +.icon-phone:before { content: "\e10f"; } -i.icon-phone-ring:before { +.icon-phone-ring:before { content: "\e110"; } -i.icon-people:before { +.icon-people:before { content: "\e111"; } -i.icon-people-female:before { +.icon-people-female:before { content: "\e112"; } -i.icon-people-alt:before { +.icon-people-alt:before { content: "\e113"; } -i.icon-people-alt-2:before { +.icon-people-alt-2:before { content: "\e114"; } -i.icon-pc:before { +.icon-pc:before { content: "\e115"; } -i.icon-pause:before { +.icon-pause:before { content: "\e116"; } -i.icon-path:before { +.icon-path:before { content: "\e117"; } -i.icon-out:before { +.icon-out:before { content: "\e118"; } -i.icon-outbox:before { +.icon-outbox:before { content: "\e119"; } -i.icon-outdent:before { +.icon-outdent:before { content: "\e11a"; } -i.icon-page-add:before { +.icon-page-add:before { content: "\e11b"; } -i.icon-page-down:before { +.icon-page-down:before { content: "\e11c"; } -i.icon-page-remove:before { +.icon-page-remove:before { content: "\e11d"; } -i.icon-page-restricted:before { +.icon-page-restricted:before { content: "\e11e"; } -i.icon-page-up:before { +.icon-page-up:before { content: "\e11f"; } -i.icon-paint-roller:before { +.icon-paint-roller:before { content: "\e120"; } -i.icon-palette:before { +.icon-palette:before { content: "\e121"; } -i.icon-newspaper:before { +.icon-newspaper:before { content: "\e122"; } -i.icon-newspaper-alt:before { +.icon-newspaper-alt:before { content: "\e123"; } -i.icon-network-alt:before { +.icon-network-alt:before { content: "\e124"; } -i.icon-navigational-arrow:before { +.icon-navigational-arrow:before { content: "\e125"; } -i.icon-navigation:before { +.icon-navigation:before { content: "\e126"; } -i.icon-navigation-vertical:before { +.icon-navigation-vertical:before { content: "\e127"; } -i.icon-navigation-up:before { +.icon-navigation-up:before { content: "\e128"; } -i.icon-navigation-top:before { +.icon-navigation-top:before { content: "\e129"; } -i.icon-navigation-road:before { +.icon-navigation-road:before { content: "\e12a"; } -i.icon-navigation-right:before { +.icon-navigation-right:before { content: "\e12b"; } -i.icon-microscope:before { +.icon-microscope:before { content: "\e12c"; } -i.icon-mindmap:before { +.icon-mindmap:before { content: "\e12d"; } -i.icon-molecular-network:before { +.icon-molecular-network:before { content: "\e12e"; } -i.icon-molecular:before { +.icon-molecular:before { content: "\e12f"; } -i.icon-mountain:before { +.icon-mountain:before { content: "\e130"; } -i.icon-mouse-cursor:before { +.icon-mouse-cursor:before { content: "\e131"; } -i.icon-mouse:before { +.icon-mouse:before { content: "\e132"; } -i.icon-movie-alt:before { +.icon-movie-alt:before { content: "\e133"; } -i.icon-map-marker:before { +.icon-map-marker:before { content: "\e134"; } -i.icon-movie:before { +.icon-movie:before { content: "\e135"; } -i.icon-map-location:before { +.icon-map-location:before { content: "\e136"; } -i.icon-map-alt:before { +.icon-map-alt:before { content: "\e137"; } -i.icon-male-symbol:before { +.icon-male-symbol:before { content: "\e138"; } -i.icon-male-and-female:before { +.icon-male-and-female:before { content: "\e139"; } -i.icon-mailbox:before { +.icon-mailbox:before { content: "\e13a"; } -i.icon-magnet:before { +.icon-magnet:before { content: "\e13b"; } -i.icon-loupe:before { +.icon-loupe:before { content: "\e13c"; } -i.icon-mobile:before { +.icon-mobile:before { content: "\e13d"; } -i.icon-logout:before { +.icon-logout:before { content: "\e13e"; } -i.icon-log-out:before { +.icon-log-out:before { content: "\e13f"; } -i.icon-layers:before { +.icon-layers:before { content: "\e140"; } -i.icon-left-double-arrow:before { +.icon-left-double-arrow:before { content: "\e141"; } -i.icon-layout:before { +.icon-layout:before { content: "\e142"; } -i.icon-legal:before { +.icon-legal:before { content: "\e143"; } -i.icon-lense:before { +.icon-lense:before { content: "\e144"; } -i.icon-library:before { +.icon-library:before { content: "\e145"; } -i.icon-light-down:before { +.icon-light-down:before { content: "\e146"; } -i.icon-light-up:before { +.icon-light-up:before { content: "\e147"; } -i.icon-lightbulb-active:before { +.icon-lightbulb-active:before { content: "\e148"; } -i.icon-lightbulb:before { +.icon-lightbulb:before { content: "\e149"; } -i.icon-ipad:before { +.icon-ipad:before { content: "\e14a"; } -i.icon-invoice:before { +.icon-invoice:before { content: "\e14b"; } -i.icon-info:before { +.icon-info:before { content: "\e14c"; } -i.icon-infinity:before { +.icon-infinity:before { content: "\e14d"; } -i.icon-indent:before { +.icon-indent:before { content: "\e14e"; } -i.icon-inbox:before { +.icon-inbox:before { content: "\e14f"; } -i.icon-inbox-full:before { +.icon-inbox-full:before { content: "\e150"; } -i.icon-inactive-line:before { +.icon-inactive-line:before { content: "\e151"; } -i.icon-imac:before { +.icon-imac:before { content: "\e152"; } -i.icon-hourglass:before { +.icon-hourglass:before { content: "\e153"; } -i.icon-home:before { +.icon-home:before { content: "\e154"; } -i.icon-grid:before { +.icon-grid:before { content: "\e155"; } -i.icon-food:before { +.icon-food:before { content: "\e156"; } -i.icon-favorite:before { +.icon-favorite:before { content: "\e157"; } -i.icon-door-open-alt:before { +.icon-door-open-alt:before { content: "\e158"; } -i.icon-diagnostics:before { +.icon-diagnostics:before { content: "\e159"; } -i.icon-contrast:before { +.icon-contrast:before { content: "\e15a"; } -i.icon-coins-dollar-alt:before { +.icon-coins-dollar-alt:before { content: "\e15b"; } -i.icon-circle-dotted-active:before { +.icon-circle-dotted-active:before { content: "\e15c"; } -i.icon-cinema:before { +.icon-cinema:before { content: "\e15d"; } -i.icon-chip:before { +.icon-chip:before { content: "\e15e"; } -i.icon-chip-alt:before { +.icon-chip-alt:before { content: "\e15f"; } -i.icon-chess:before { +.icon-chess:before { content: "\e160"; } -i.icon-checkbox:before { +.icon-checkbox:before { content: "\e161"; } -i.icon-checkbox-empty:before { +.icon-checkbox-empty:before { content: "\e162"; } -i.icon-checkbox-dotted:before { +.icon-checkbox-dotted:before { content: "\e163"; } -i.icon-checkbox-dotted-active:before { +.icon-checkbox-dotted-active:before { content: "\e164"; } -i.icon-check:before { +.icon-check:before { content: "\e165"; } -i.icon-chat:before { +.icon-chat:before { content: "\e166"; } -i.icon-chat-active:before { +.icon-chat-active:before { content: "\e167"; } -i.icon-chart:before { +.icon-chart:before { content: "\e168"; } -i.icon-chart-curve:before { +.icon-chart-curve:before { content: "\e169"; } -i.icon-certificate:before { +.icon-certificate:before { content: "\e16a"; } -i.icon-categories:before { +.icon-categories:before { content: "\e16b"; } -i.icon-cash-register:before { +.icon-cash-register:before { content: "\e16c"; } -i.icon-car:before { +.icon-car:before { content: "\e16d"; } -i.icon-caps-lock:before { +.icon-caps-lock:before { content: "\e16e"; } -i.icon-candy:before { +.icon-candy:before { content: "\e16f"; } -i.icon-circle-dotted:before { +.icon-circle-dotted:before { content: "\e170"; } -i.icon-circuits:before { +.icon-circuits:before { content: "\e171"; } -i.icon-circus:before { +.icon-circus:before { content: "\e172"; } -i.icon-client:before { +.icon-client:before { content: "\e173"; } -i.icon-clothes-hanger:before { +.icon-clothes-hanger:before { content: "\e174"; } -i.icon-cloud-drive:before { +.icon-cloud-drive:before { content: "\e175"; } -i.icon-cloud-upload:before { +.icon-cloud-upload:before { content: "\e176"; } -i.icon-cloud:before { +.icon-cloud:before { content: "\e177"; } -i.icon-cloudy:before { +.icon-cloudy:before { content: "\e178"; } -i.icon-clubs:before { +.icon-clubs:before { content: "\e179"; } -i.icon-cocktail:before { +.icon-cocktail:before { content: "\e17a"; } -i.icon-code:before { +.icon-code:before { content: "\e17b"; } -i.icon-coffee:before { +.icon-coffee:before { content: "\e17c"; } -i.icon-coin-dollar:before { +.icon-coin-dollar:before { content: "\e17d"; } -i.icon-coin-pound:before { +.icon-coin-pound:before { content: "\e17e"; } -i.icon-coin-yen:before { +.icon-coin-yen:before { content: "\e17f"; } -i.icon-coin:before { +.icon-coin:before { content: "\e180"; } -i.icon-coins-alt:before { +.icon-coins-alt:before { content: "\e181"; } -i.icon-console:before { +.icon-console:before { content: "\e182"; } -i.icon-connection:before { +.icon-connection:before { content: "\e183"; } -i.icon-compress:before { +.icon-compress:before { content: "\e184"; } -i.icon-company:before { +.icon-company:before { content: "\e185"; } -i.icon-command:before { +.icon-command:before { content: "\e186"; } -i.icon-coin-euro:before { +.icon-coin-euro:before { content: "\e187"; } -i.icon-combination-lock:before { +.icon-combination-lock:before { content: "\e188"; } -i.icon-combination-lock-open:before { +.icon-combination-lock-open:before { content: "\e189"; } -i.icon-comb:before { +.icon-comb:before { content: "\e18a"; } -i.icon-columns:before { +.icon-columns:before { content: "\e18b"; } -i.icon-colorpicker:before { +.icon-colorpicker:before { content: "\e18c"; } -i.icon-color-bucket:before { +.icon-color-bucket:before { content: "\e18d"; } -i.icon-coins:before { +.icon-coins:before { content: "\e18e"; } -i.icon-coins-yen:before { +.icon-coins-yen:before { content: "\e18f"; } -i.icon-coins-yen-alt:before { +.icon-coins-yen-alt:before { content: "\e190"; } -i.icon-coins-pound:before { +.icon-coins-pound:before { content: "\e191"; } -i.icon-coins-pound-alt:before { +.icon-coins-pound-alt:before { content: "\e192"; } -i.icon-coins-euro:before { +.icon-coins-euro:before { content: "\e193"; } -i.icon-coins-euro-alt:before { +.icon-coins-euro-alt:before { content: "\e194"; } -i.icon-coins-dollar:before { +.icon-coins-dollar:before { content: "\e195"; } -i.icon-conversation-alt:before { +.icon-conversation-alt:before { content: "\e196"; } -i.icon-conversation:before { +.icon-conversation:before { content: "\e197"; } -i.icon-coverflow:before { +.icon-coverflow:before { content: "\e198"; } -i.icon-credit-card-alt:before { +.icon-credit-card-alt:before { content: "\e199"; } -i.icon-credit-card:before { +.icon-credit-card:before { content: "\e19a"; } -i.icon-crop:before { +.icon-crop:before { content: "\e19b"; } -i.icon-crosshair:before { +.icon-crosshair:before { content: "\e19c"; } -i.icon-crown-alt:before { +.icon-crown-alt:before { content: "\e19d"; } -i.icon-crown:before { +.icon-crown:before { content: "\e19e"; } -i.icon-cupcake:before { +.icon-cupcake:before { content: "\e19f"; } -i.icon-curve:before { +.icon-curve:before { content: "\e1a0"; } -i.icon-cut:before { +.icon-cut:before { content: "\e1a1"; } -i.icon-dashboard:before { +.icon-dashboard:before { content: "\e1a2"; } -i.icon-defrag:before { +.icon-defrag:before { content: "\e1a3"; } -i.icon-delete:before { +.icon-delete:before { content: "\e1a4"; } -i.icon-delete-key:before { +.icon-delete-key:before { content: "\e1a5"; } -i.icon-departure:before { +.icon-departure:before { content: "\e1a6"; } -i.icon-desk:before { +.icon-desk:before { content: "\e1a7"; } -i.icon-desktop:before { +.icon-desktop:before { content: "\e1a8"; } -i.icon-donate:before { +.icon-donate:before { content: "\e1a9"; } -i.icon-dollar-bag:before { +.icon-dollar-bag:before { content: "\e1aa"; } -i.icon-documents:before { +.icon-documents:before { content: "\e1ab"; } -i.icon-document:before { +.icon-document:before { content: "\e1ac"; } -i.icon-document-dashed-line:before { +.icon-document-dashed-line:before { content: "\e1ad"; } -i.icon-dock-connector:before { +.icon-dock-connector:before { content: "\e1ae"; } -i.icon-dna:before { +.icon-dna:before { content: "\e1af"; } -i.icon-display:before { +.icon-display:before { content: "\e1b0"; } -i.icon-disk-image:before { +.icon-disk-image:before { content: "\e1b1"; } -i.icon-disc:before { +.icon-disc:before { content: "\e1b2"; } -i.icon-directions:before { +.icon-directions:before { content: "\e1b3"; } -i.icon-directions-alt:before { +.icon-directions-alt:before { content: "\e1b4"; } -i.icon-diploma:before { +.icon-diploma:before { content: "\e1b5"; } -i.icon-diploma-alt:before { +.icon-diploma-alt:before { content: "\e1b6"; } -i.icon-dice:before { +.icon-dice:before { content: "\e1b7"; } -i.icon-diamonds:before { +.icon-diamonds:before { content: "\e1b8"; } -i.icon-diamond:before { +.icon-diamond:before { content: "\e1b9"; } -i.icon-diagonal-arrow:before { +.icon-diagonal-arrow:before { content: "\e1ba"; } -i.icon-diagonal-arrow-alt:before { +.icon-diagonal-arrow-alt:before { content: "\e1bb"; } -i.icon-door-open:before { +.icon-door-open:before { content: "\e1bc"; } -i.icon-download-alt:before { +.icon-download-alt:before { content: "\e1bd"; } -i.icon-download:before { +.icon-download:before { content: "\e1be"; } -i.icon-drop:before { +.icon-drop:before { content: "\e1bf"; } -i.icon-eco:before { +.icon-eco:before { content: "\e1c0"; } -i.icon-economy:before { +.icon-economy:before { content: "\e1c1"; } -i.icon-edit:before { +.icon-edit:before { content: "\e1c2"; } -i.icon-eject:before { +.icon-eject:before { content: "\e1c3"; } -i.icon-employee:before { +.icon-employee:before { content: "\e1c4"; } -i.icon-energy-saving-bulb:before { +.icon-energy-saving-bulb:before { content: "\e1c5"; } -i.icon-enter:before { +.icon-enter:before { content: "\e1c6"; } -i.icon-equalizer:before { +.icon-equalizer:before { content: "\e1c7"; } -i.icon-escape:before { +.icon-escape:before { content: "\e1c8"; } -i.icon-ethernet:before { +.icon-ethernet:before { content: "\e1c9"; } -i.icon-euro-bag:before { +.icon-euro-bag:before { content: "\e1ca"; } -i.icon-exit-fullscreen:before { +.icon-exit-fullscreen:before { content: "\e1cb"; } -i.icon-eye:before { +.icon-eye:before { content: "\e1cc"; } -i.icon-facebook-like:before { +.icon-facebook-like:before { content: "\e1cd"; } -i.icon-factory:before { +.icon-factory:before { content: "\e1ce"; } -i.icon-font:before { +.icon-font:before { content: "\e1cf"; } -i.icon-folders:before { +.icon-folders:before { content: "\e1d0"; } -i.icon-folder:before, i.icon-folder-close:before { +.icon-folder:before, .icon-folder-close:before { content: "\e1d1"; } -i.icon-folder-outline:before { +.icon-folder-outline:before { content: "\e1d2"; } -i.icon-folder-open:before { +.icon-folder-open:before { content: "\e1d3"; } -i.icon-flowerpot:before { +.icon-flowerpot:before { content: "\e1d4"; } -i.icon-flashlight:before { +.icon-flashlight:before { content: "\e1d5"; } -i.icon-flash:before { +.icon-flash:before { content: "\e1d6"; } -i.icon-flag:before { +.icon-flag:before { content: "\e1d7"; } -i.icon-flag-alt:before { +.icon-flag-alt:before { content: "\e1d8"; } -i.icon-firewire:before { +.icon-firewire:before { content: "\e1d9"; } -i.icon-firewall:before { +.icon-firewall:before { content: "\e1da"; } -i.icon-fire:before { +.icon-fire:before { content: "\e1db"; } -i.icon-fingerprint:before { +.icon-fingerprint:before { content: "\e1dc"; } -i.icon-filter:before { +.icon-filter:before { content: "\e1dd"; } -i.icon-filter-arrows:before { +.icon-filter-arrows:before { content: "\e1de"; } -i.icon-files:before { +.icon-files:before { content: "\e1df"; } -i.icon-file-cabinet:before { +.icon-file-cabinet:before { content: "\e1e0"; } -i.icon-female-symbol:before { +.icon-female-symbol:before { content: "\e1e1"; } -i.icon-footprints:before { +.icon-footprints:before { content: "\e1e2"; } -i.icon-hammer:before { +.icon-hammer:before { content: "\e1e3"; } -i.icon-hand-active-alt:before { +.icon-hand-active-alt:before { content: "\e1e4"; } -i.icon-forking:before { +.icon-forking:before { content: "\e1e5"; } -i.icon-hand-active:before { +.icon-hand-active:before { content: "\e1e6"; } -i.icon-hand-pointer-alt:before { +.icon-hand-pointer-alt:before { content: "\e1e7"; } -i.icon-hand-pointer:before { +.icon-hand-pointer:before { content: "\e1e8"; } -i.icon-handprint:before { +.icon-handprint:before { content: "\e1e9"; } -i.icon-handshake:before { +.icon-handshake:before { content: "\e1ea"; } -i.icon-handtool:before { +.icon-handtool:before { content: "\e1eb"; } -i.icon-hard-drive:before { +.icon-hard-drive:before { content: "\e1ec"; } -i.icon-help:before { +.icon-help:before { content: "\e1ed"; } -i.icon-graduate:before { +.icon-graduate:before { content: "\e1ee"; } -i.icon-gps:before { +.icon-gps:before { content: "\e1ef"; } -i.icon-help-alt:before { +.icon-help-alt:before { content: "\e1f0"; } -i.icon-height:before { +.icon-height:before { content: "\e1f1"; } -i.icon-globe:before { +.icon-globe:before { content: "\e1f2"; } -i.icon-hearts:before { +.icon-hearts:before { content: "\e1f3"; } -i.icon-globe-inverted-europe-africa:before { +.icon-globe-inverted-europe-africa:before { content: "\e1f4"; } -i.icon-headset:before { +.icon-headset:before { content: "\e1f5"; } -i.icon-globe-inverted-asia:before { +.icon-globe-inverted-asia:before { content: "\e1f6"; } -i.icon-headphones:before { +.icon-headphones:before { content: "\e1f7"; } -i.icon-globe-inverted-america:before { +.icon-globe-inverted-america:before { content: "\e1f8"; } -i.icon-hd:before { +.icon-hd:before { content: "\e1f9"; } -i.icon-globe-europe-africa:before, -i.icon-globe-europe---africa:before { +.icon-globe-europe-africa:before, +.icon-globe-europe---africa:before { content: "\e1fa"; } -i.icon-hat:before { +.icon-hat:before { content: "\e1fb"; } -i.icon-globe-asia:before { +.icon-globe-asia:before { content: "\e1fc"; } -i.icon-globe-alt:before { +.icon-globe-alt:before { content: "\e1fd"; } -i.icon-hard-drive-alt:before { +.icon-hard-drive-alt:before { content: "\e1fe"; } -i.icon-glasses:before { +.icon-glasses:before { content: "\e1ff"; } -i.icon-gift:before { +.icon-gift:before { content: "\e200"; } -i.icon-handtool-alt:before { +.icon-handtool-alt:before { content: "\e201"; } -i.icon-geometry:before { +.icon-geometry:before { content: "\e202"; } -i.icon-game:before { +.icon-game:before { content: "\e203"; } -i.icon-fullscreen:before { +.icon-fullscreen:before { content: "\e204"; } -i.icon-fullscreen-alt:before { +.icon-fullscreen-alt:before { content: "\e205"; } -i.icon-frame:before { +.icon-frame:before { content: "\e206"; } -i.icon-frame-alt:before { +.icon-frame-alt:before { content: "\e207"; } -i.icon-camera-roll:before { +.icon-camera-roll:before { content: "\e208"; } -i.icon-bookmark:before { +.icon-bookmark:before { content: "\e209"; } -i.icon-bill:before { +.icon-bill:before { content: "\e20a"; } -i.icon-baby-stroller:before { +.icon-baby-stroller:before { content: "\e20b"; } -i.icon-alarm-clock:before { +.icon-alarm-clock:before { content: "\e20c"; } -i.icon-addressbook:before, -i.icon-adressbook:before { +.icon-addressbook:before, +.icon-adressbook:before { content: "\e20d"; } -i.icon-add:before { +.icon-add:before { content: "\e20e"; } -i.icon-activity:before { +.icon-activity:before { content: "\e20f"; } -i.icon-untitled:before { +.icon-untitled:before { content: "\e210"; } -i.icon-glasses:before { +.icon-glasses:before { content: "\e211"; } -i.icon-camcorder:before { +.icon-camcorder:before { content: "\e212"; } -i.icon-calendar:before { +.icon-calendar:before { content: "\e213"; } -i.icon-calendar-alt:before { +.icon-calendar-alt:before { content: "\e214"; } -i.icon-calculator:before { +.icon-calculator:before { content: "\e215"; } -i.icon-bus:before { +.icon-bus:before { content: "\e216"; } -i.icon-burn:before { +.icon-burn:before { content: "\e217"; } -i.icon-bulleted-list:before { +.icon-bulleted-list:before { content: "\e218"; } -i.icon-bug:before { +.icon-bug:before { content: "\e219"; } -i.icon-brush:before { +.icon-brush:before { content: "\e21a"; } -i.icon-brush-alt:before { +.icon-brush-alt:before { content: "\e21b"; } -i.icon-brush-alt-2:before { +.icon-brush-alt-2:before { content: "\e21c"; } -i.icon-browser-window:before { +.icon-browser-window:before { content: "\e21d"; } -i.icon-briefcase:before { +.icon-briefcase:before { content: "\e21e"; } -i.icon-brick:before { +.icon-brick:before { content: "\e21f"; } -i.icon-brackets:before { +.icon-brackets:before { content: "\e220"; } -i.icon-box:before { +.icon-box:before { content: "\e221"; } -i.icon-box-open:before { +.icon-box-open:before { content: "\e222"; } -i.icon-box-alt:before { +.icon-box-alt:before { content: "\e223"; } -i.icon-books:before { +.icon-books:before { content: "\e224"; } -i.icon-billboard:before { +.icon-billboard:before { content: "\e225"; } -i.icon-bills-dollar:before { +.icon-bills-dollar:before { content: "\e226"; } -i.icon-bills-euro:before { +.icon-bills-euro:before { content: "\e227"; } -i.icon-bills-pound:before { +.icon-bills-pound:before { content: "\e228"; } -i.icon-bills-yen:before { +.icon-bills-yen:before { content: "\e229"; } -i.icon-bills:before { +.icon-bills:before { content: "\e22a"; } -i.icon-binarycode:before { +.icon-binarycode:before { content: "\e22b"; } -i.icon-binoculars:before { +.icon-binoculars:before { content: "\e22c"; } -i.icon-bird:before { +.icon-bird:before { content: "\e22d"; } -i.icon-birthday-cake:before { +.icon-birthday-cake:before { content: "\e22e"; } -i.icon-blueprint:before { +.icon-blueprint:before { content: "\e22f"; } -i.icon-block:before { +.icon-block:before { content: "\e230"; } -i.icon-bluetooth:before { +.icon-bluetooth:before { content: "\e231"; } -i.icon-boat-shipping:before { +.icon-boat-shipping:before { content: "\e232"; } -i.icon-bomb:before { +.icon-bomb:before { content: "\e233"; } -i.icon-book-alt-2:before { +.icon-book-alt-2:before { content: "\e234"; } -i.icon-bones:before { +.icon-bones:before { content: "\e235"; } -i.icon-book-alt:before { +.icon-book-alt:before { content: "\e236"; } -i.icon-book:before { +.icon-book:before { content: "\e237"; } -i.icon-bill-yen:before { +.icon-bill-yen:before { content: "\e238"; } -i.icon-award:before { +.icon-award:before { content: "\e239"; } -i.icon-bill-pound:before { +.icon-bill-pound:before { content: "\e23a"; } -i.icon-autofill:before { +.icon-autofill:before { content: "\e23b"; } -i.icon-bill-euro:before { +.icon-bill-euro:before { content: "\e23c"; } -i.icon-auction-hammer:before { +.icon-auction-hammer:before { content: "\e23d"; } -i.icon-bill-dollar:before { +.icon-bill-dollar:before { content: "\e23e"; } -i.icon-attachment:before { +.icon-attachment:before { content: "\e23f"; } -i.icon-bell:before { +.icon-bell:before { content: "\e240"; } -i.icon-article:before { +.icon-article:before { content: "\e241"; } -i.icon-bell-off:before { +.icon-bell-off:before { content: "\e242"; } -i.icon-art-easel:before { +.icon-art-easel:before { content: "\e243"; } -i.icon-beer-glass:before { +.icon-beer-glass:before { content: "\e244"; } -i.icon-arrow-up:before { +.icon-arrow-up:before { content: "\e245"; } -i.icon-battery-low:before { +.icon-battery-low:before { content: "\e246"; } -i.icon-arrow-right:before { +.icon-arrow-right:before { content: "\e247"; } -i.icon-battery-full:before { +.icon-battery-full:before { content: "\e248"; } -i.icon-arrow-left:before { +.icon-arrow-left:before { content: "\e249"; } -i.icon-bars:before { +.icon-bars:before { content: "\e24a"; } -i.icon-arrow-down:before { +.icon-arrow-down:before { content: "\e24b"; } -i.icon-barcode:before { +.icon-barcode:before { content: "\e24c"; } -i.icon-arrivals:before { +.icon-arrivals:before { content: "\e24d"; } -i.icon-bar-chart:before { +.icon-bar-chart:before { content: "\e24e"; } -i.icon-application-window:before { +.icon-application-window:before { content: "\e24f"; } -i.icon-band-aid:before { +.icon-band-aid:before { content: "\e250"; } -i.icon-application-window-alt:before { +.icon-application-window-alt:before { content: "\e251"; } -i.icon-ball:before { +.icon-ball:before { content: "\e252"; } -i.icon-application-error:before { +.icon-application-error:before { content: "\e253"; } -i.icon-badge-restricted:before { +.icon-badge-restricted:before { content: "\e254"; } -i.icon-app:before { +.icon-app:before { content: "\e255"; } -i.icon-badge-remove:before { +.icon-badge-remove:before { content: "\e256"; } -i.icon-anchor:before { +.icon-anchor:before { content: "\e257"; } -i.icon-badge-count:before { +.icon-badge-count:before { content: "\e258"; } -i.icon-alt:before { +.icon-alt:before { content: "\e259"; } -i.icon-badge-add:before { +.icon-badge-add:before { content: "\e25a"; } -i.icon-alert:before { +.icon-alert:before { content: "\e25b"; } -i.icon-backspace:before { +.icon-backspace:before { content: "\e25c"; } -i.icon-alert-alt:before { +.icon-alert-alt:before { content: "\e25d"; } -i.icon-section:before { +.icon-section:before { content: "\e24f"; } diff --git a/src/Umbraco.Web.UI.Client/src/less/main.less b/src/Umbraco.Web.UI.Client/src/less/main.less index 3bf00fb25c..e1e368f2e2 100644 --- a/src/Umbraco.Web.UI.Client/src/less/main.less +++ b/src/Umbraco.Web.UI.Client/src/less/main.less @@ -208,6 +208,13 @@ umb-property:last-of-type .umb-control-group { .control-description { display: block; clear: both; + overflow-wrap: break-word; + } + + &::after { + content: ''; + display: block; + clear: both; } } diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html index 6dea4debb6..4b08d4e5fc 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html @@ -16,7 +16,7 @@
-
/// [SetAngularAntiForgeryTokens] + [DenyLocalLoginAuthorization] public async Task PostRequestPasswordReset(RequestPasswordResetModel model) { // If this feature is switched off in configuration the UI will be amended to not make the request to reset password available. @@ -476,7 +504,12 @@ namespace Umbraco.Web.Editors if (UserManager != null) { int.TryParse(User.Identity.GetUserId(), out var userId); - UserManager.RaiseLogoutSuccessEvent(userId); + var args = UserManager.RaiseLogoutSuccessEvent(userId); + if (!args.SignOutRedirectUrl.IsNullOrWhiteSpace()) + return Request.CreateResponse(new + { + signOutRedirectUrl = args.SignOutRedirectUrl + }); } return Request.CreateResponse(HttpStatusCode.OK); diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index cd5a3a50b6..18740d41fc 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -4,7 +4,6 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; -using System.Web; using System.Web.Mvc; using System.Web.UI; using Microsoft.AspNet.Identity; @@ -113,7 +112,7 @@ namespace Umbraco.Web.Editors //if you are hitting VerifyInvite, you're already signed in as a different user, and the token is invalid //you'll exit on one of the return RedirectToAction("Default") but you're still logged in so you just get //dumped at the default admin view with no detail - if(Security.IsAuthenticated()) + if (Security.IsAuthenticated()) { AuthenticationManager.SignOut( Core.Constants.Security.BackOfficeAuthenticationType, @@ -213,7 +212,7 @@ namespace Umbraco.Web.Editors { var slashIndex = kv.Key.IndexOf('/'); var areaAlias = kv.Key.Substring(0, slashIndex); - var valueAlias = kv.Key.Substring(slashIndex+1); + var valueAlias = kv.Key.Substring(slashIndex + 1); return new { areaAlias, @@ -333,7 +332,7 @@ namespace Umbraco.Web.Editors } [HttpGet] - public async Task ValidatePasswordResetCode([Bind(Prefix = "u")]int userId, [Bind(Prefix = "r")]string resetCode) + public async Task ValidatePasswordResetCode([Bind(Prefix = "u")] int userId, [Bind(Prefix = "r")] string resetCode) { var user = UserManager.FindById(userId); if (user != null) @@ -391,8 +390,9 @@ namespace Umbraco.Web.Editors ViewData.SetUmbracoPath(GlobalSettings.GetUmbracoMvcArea()); - //check if there is the TempData with the any token name specified, if so, assign to view bag and render the view - if (ViewData.FromTempData(TempData, ViewDataExtensions.TokenExternalSignInError) || + //check if there is the TempData or cookies with the any token name specified, if so, assign to view bag and render the view + if (ViewData.FromBase64CookieData(HttpContext, ViewDataExtensions.TokenExternalSignInError) || + ViewData.FromTempData(TempData, ViewDataExtensions.TokenExternalSignInError) || ViewData.FromTempData(TempData, ViewDataExtensions.TokenPasswordResetCode)) return defaultResponse(); @@ -402,6 +402,17 @@ namespace Umbraco.Web.Editors if (loginInfo == null || loginInfo.ExternalIdentity.IsAuthenticated == false) { + + // if the user is not logged in, check if there's any auto login redirects specified + if (UmbracoContext.Security.ValidateCurrentUser(false) != ValidateRequestAttempt.Success) + { + var oauthRedirectAuthProvider = OwinContext.Authentication.GetAutoLoginProvider(); + if (!oauthRedirectAuthProvider.IsNullOrWhiteSpace()) + { + return ExternalLogin(oauthRedirectAuthProvider); + } + } + return defaultResponse(); } @@ -415,7 +426,7 @@ namespace Umbraco.Web.Editors if (response == null) throw new ArgumentNullException("response"); ExternalSignInAutoLinkOptions autoLinkOptions = null; - //Here we can check if the provider associated with the request has been configured to allow + // Here we can check if the provider associated with the request has been configured to allow // new users (auto-linked external accounts). This would never be used with public providers such as // Google, unless you for some reason wanted anybody to be able to access the backend if they have a Google account // .... not likely! @@ -426,19 +437,13 @@ namespace Umbraco.Web.Editors } else { - autoLinkOptions = authType.GetExternalAuthenticationOptions(); + autoLinkOptions = authType.GetExternalSignInAutoLinkOptions(); } // Sign in the user with this external login provider if the user already has a login var user = await UserManager.FindAsync(loginInfo.Login); if (user != null) { - // TODO: It might be worth keeping some of the claims associated with the ExternalLoginInfo, in which case we - // wouldn't necessarily sign the user in here with the standard login, instead we'd update the - // UseUmbracoBackOfficeExternalCookieAuthentication extension method to have the correct provider and claims factory, - // ticket format, etc.. to create our back office user including the claims assigned and in this method we'd just ensure - // that the ticket is created and stored and that the user is logged in. - var shouldSignIn = true; if (autoLinkOptions != null && autoLinkOptions.OnExternalLogin != null) { @@ -459,7 +464,10 @@ namespace Umbraco.Web.Editors { if (await AutoLinkAndSignInExternalAccount(loginInfo, autoLinkOptions) == false) { - ViewData.SetExternalSignInError(new[] { "The requested provider (" + loginInfo.Login.LoginProvider + ") has not been linked to an account" }); + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.Login.LoginProvider, + new[] { "The requested provider (" + loginInfo.Login.LoginProvider + ") has not been linked to an account" })); } //Remove the cookie otherwise this message will keep appearing @@ -483,15 +491,34 @@ namespace Umbraco.Web.Editors //we are allowing auto-linking/creating of local accounts if (loginInfo.Email.IsNullOrWhiteSpace()) { - ViewData.SetExternalSignInError(new[] { "The requested provider (" + loginInfo.Login.LoginProvider + ") has not provided an email address, the account cannot be linked." }); + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.Login.LoginProvider, + new[] { "The requested provider (" + loginInfo.Login.LoginProvider + ") has not provided an email address, the account cannot be linked." })); } else { //Now we need to perform the auto-link, so first we need to lookup/create a user with the email address - var foundByEmail = Services.UserService.GetByEmail(loginInfo.Email); - if (foundByEmail != null) + var autoLinkUser = UserManager.FindByEmail(loginInfo.Email); + if (autoLinkUser != null) { - ViewData.SetExternalSignInError(new[] { "A user with this email address already exists locally. You will need to login locally to Umbraco and link this external provider: " + loginInfo.Login.LoginProvider }); + try + { + //call the callback if one is assigned + autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); + } + catch (Exception ex) + { + var msg = "Could not link login provider " + loginInfo.Login.LoginProvider + "."; + Logger.Error(ex, msg); + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.Login.LoginProvider, + new[] { msg + " " + ex.Message })); + return true; + } + + await LinkUser(autoLinkUser, loginInfo); } else { @@ -500,7 +527,7 @@ namespace Umbraco.Web.Editors var groups = Services.UserService.GetUserGroupsByAlias(autoLinkOptions.GetDefaultUserGroups(UmbracoContext, loginInfo)); - var autoLinkUser = BackOfficeIdentityUser.CreateNew( + autoLinkUser = BackOfficeIdentityUser.CreateNew( loginInfo.Email, loginInfo.Email, autoLinkOptions.GetDefaultCulture(UmbracoContext, loginInfo)); @@ -511,37 +538,33 @@ namespace Umbraco.Web.Editors } //call the callback if one is assigned - if (autoLinkOptions.OnAutoLinking != null) + try { - autoLinkOptions.OnAutoLinking(autoLinkUser, loginInfo); + autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); + } + catch (Exception ex) + { + var msg = "Could not link login provider " + loginInfo.Login.LoginProvider + "."; + Logger.Error(ex, msg); + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.Login.LoginProvider, + new[] { msg + " " + ex.Message })); + return true; } var userCreationResult = await UserManager.CreateAsync(autoLinkUser); if (userCreationResult.Succeeded == false) { - ViewData.SetExternalSignInError(userCreationResult.Errors); + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.Login.LoginProvider, + userCreationResult.Errors)); } else { - var linkResult = await UserManager.AddLoginAsync(autoLinkUser.Id, loginInfo.Login); - if (linkResult.Succeeded == false) - { - ViewData.SetExternalSignInError(linkResult.Errors); - - //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 == false) - { - //DOH! ... this isn't good, combine all errors to be shown - ViewData.SetExternalSignInError(linkResult.Errors.Concat(deleteResult.Errors)); - } - } - else - { - //sign in - await SignInManager.SignInAsync(autoLinkUser, isPersistent: false, rememberBrowser: false); - } + await LinkUser(autoLinkUser, loginInfo); } } @@ -549,6 +572,44 @@ namespace Umbraco.Web.Editors return true; } + private async Task LinkUser(BackOfficeIdentityUser autoLinkUser, ExternalLoginInfo loginInfo) + { + var existingLogins = await UserManager.GetLoginsAsync(autoLinkUser.Id); + var exists = existingLogins.FirstOrDefault(x => x.LoginProvider == loginInfo.Login.LoginProvider && x.ProviderKey == loginInfo.Login.ProviderKey); + + // if it already exists (perhaps it was added in the AutoLink callbak) then we just continue + if (exists != null) + { + //sign in + await SignInManager.SignInAsync(autoLinkUser, isPersistent: false, rememberBrowser: false); + return; + } + + var linkResult = await UserManager.AddLoginAsync(autoLinkUser.Id, loginInfo.Login); + if (linkResult.Succeeded) + { + //we're good! sign in + await SignInManager.SignInAsync(autoLinkUser, isPersistent: false, rememberBrowser: false); + return; + } + + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.Login.LoginProvider, + linkResult.Errors)); + + //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) + { + //DOH! ... this isn't good, combine all errors to be shown + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.Login.LoginProvider, + linkResult.Errors.Concat(deleteResult.Errors))); + } + } + private ActionResult RedirectToLocal(string returnUrl) { if (Url.IsLocalUrl(returnUrl)) @@ -559,50 +620,8 @@ namespace Umbraco.Web.Editors } // Used for XSRF protection when adding external logins - private const string XsrfKey = "XsrfId"; + public const string XsrfKey = "XsrfId"; - private class ChallengeResult : HttpUnauthorizedResult - { - public ChallengeResult(string provider, string redirectUri, string userId = null) - { - LoginProvider = provider; - RedirectUri = redirectUri; - UserId = userId; - } - - private string LoginProvider { get; set; } - private string RedirectUri { get; set; } - private string UserId { get; set; } - - public override void ExecuteResult(ControllerContext context) - { - //Ensure the forms auth module doesn't do a redirect! - context.HttpContext.Response.SuppressFormsAuthenticationRedirect = true; - - var owinCtx = context.HttpContext.GetOwinContext(); - - //First, see if a custom challenge result callback is specified for the provider - // and use it instead of the default if one is supplied. - var loginProvider = owinCtx.Authentication - .GetExternalAuthenticationTypes() - .FirstOrDefault(p => p.AuthenticationType == LoginProvider); - if (loginProvider != null) - { - var providerChallengeResult = loginProvider.GetSignInChallengeResult(owinCtx); - if (providerChallengeResult != null) - { - owinCtx.Authentication.Challenge(providerChallengeResult, LoginProvider); - return; - } - } - - var properties = new AuthenticationProperties() { RedirectUri = RedirectUri.EnsureEndsWith('/') }; - if (UserId != null) - { - properties.Dictionary[XsrfKey] = UserId; - } - owinCtx.Authentication.Challenge(properties, LoginProvider); - } - } } + } diff --git a/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs b/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs index 60d3a6f779..99f6161ce2 100644 --- a/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs +++ b/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs @@ -22,6 +22,7 @@ using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Mvc; using Umbraco.Web.Profiling; using Umbraco.Web.PropertyEditors; +using Umbraco.Web.Security; using Umbraco.Web.Trees; using Constants = Umbraco.Core.Constants; @@ -379,8 +380,7 @@ namespace Umbraco.Web.Editors "externalLogins", new Dictionary { { - "providers", _owinContext.Authentication.GetExternalAuthenticationTypes() - .Where(p => p.Properties.ContainsKey("UmbracoBackOffice")) + "providers", _owinContext.Authentication.GetBackOfficeExternalLoginProviders() .Select(p => new { authType = p.AuthenticationType, caption = p.Caption, diff --git a/src/Umbraco.Web/Editors/ChallengeResult.cs b/src/Umbraco.Web/Editors/ChallengeResult.cs new file mode 100644 index 0000000000..00c0aa187d --- /dev/null +++ b/src/Umbraco.Web/Editors/ChallengeResult.cs @@ -0,0 +1,54 @@ +using System.Linq; +using System.Web; +using System.Web.Mvc; +using Microsoft.Owin.Security; +using Umbraco.Core; +using Umbraco.Web.Security; + +namespace Umbraco.Web.Editors +{ + public class ChallengeResult : HttpUnauthorizedResult + { + public ChallengeResult(string provider, string redirectUri, string userId = null) + { + LoginProvider = provider; + RedirectUri = redirectUri; + UserId = userId; + } + + private string LoginProvider { get; set; } + private string RedirectUri { get; set; } + private string UserId { get; set; } + + public override void ExecuteResult(ControllerContext context) + { + //Ensure the forms auth module doesn't do a redirect! + context.HttpContext.Response.SuppressFormsAuthenticationRedirect = true; + + var owinCtx = context.HttpContext.GetOwinContext(); + + //First, see if a custom challenge result callback is specified for the provider + // and use it instead of the default if one is supplied. + var loginProvider = owinCtx.Authentication + .GetExternalAuthenticationTypes() + .FirstOrDefault(p => p.AuthenticationType == LoginProvider); + if (loginProvider != null) + { + var providerChallengeResult = loginProvider.GetSignInChallengeResult(owinCtx); + if (providerChallengeResult != null) + { + owinCtx.Authentication.Challenge(providerChallengeResult, LoginProvider); + return; + } + } + + var properties = new AuthenticationProperties() { RedirectUri = RedirectUri.EnsureEndsWith('/') }; + if (UserId != null) + { + properties.Dictionary[BackOfficeController.XsrfKey] = UserId; + } + owinCtx.Authentication.Challenge(properties, LoginProvider); + } + } + +} diff --git a/src/Umbraco.Web/Editors/Filters/DenyLocalLoginAuthorizationAttribute.cs b/src/Umbraco.Web/Editors/Filters/DenyLocalLoginAuthorizationAttribute.cs new file mode 100644 index 0000000000..89a67d8f78 --- /dev/null +++ b/src/Umbraco.Web/Editors/Filters/DenyLocalLoginAuthorizationAttribute.cs @@ -0,0 +1,18 @@ +using System.Web.Http; +using System.Web.Http.Controllers; +using Umbraco.Web.WebApi; +using Umbraco.Web.Security; + +namespace Umbraco.Web.Editors.Filters +{ + internal class DenyLocalLoginAuthorizationAttribute : AuthorizeAttribute + { + protected override bool IsAuthorized(HttpActionContext actionContext) + { + var owinContext = actionContext.Request.TryGetOwinContext().Result; + + // no authorization if any external logins deny local login + return !owinContext.Authentication.HasDenyLocalLogin(); + } + } +} diff --git a/src/Umbraco.Web/Editors/Filters/IsCurrentUserModelFilterAttribute.cs b/src/Umbraco.Web/Editors/Filters/IsCurrentUserModelFilterAttribute.cs index b9c6eb45eb..61122e2b6e 100644 --- a/src/Umbraco.Web/Editors/Filters/IsCurrentUserModelFilterAttribute.cs +++ b/src/Umbraco.Web/Editors/Filters/IsCurrentUserModelFilterAttribute.cs @@ -22,9 +22,9 @@ namespace Umbraco.Web.Editors.Filters if (objectContent != null) { var model = objectContent.Value as UserBasic; - if (model != null) + if (model != null && model.Id is int userId) { - model.IsCurrentUser = (int) model.Id == user.Id; + model.IsCurrentUser = userId == user.Id; } else { @@ -33,7 +33,10 @@ namespace Umbraco.Web.Editors.Filters { foreach (var userBasic in collection) { - userBasic.IsCurrentUser = (int) userBasic.Id == user.Id; + if (userBasic.Id is int uid) + { + userBasic.IsCurrentUser = uid == user.Id; + } } } else @@ -43,7 +46,10 @@ namespace Umbraco.Web.Editors.Filters { foreach (var userBasic in paged.Items) { - userBasic.IsCurrentUser = (int)userBasic.Id == user.Id; + if (userBasic.Id is int uid) + { + userBasic.IsCurrentUser = uid == user.Id; + } } } } diff --git a/src/Umbraco.Web/Editors/UsersController.cs b/src/Umbraco.Web/Editors/UsersController.cs index 76c4228a01..9dff4e32c1 100644 --- a/src/Umbraco.Web/Editors/UsersController.cs +++ b/src/Umbraco.Web/Editors/UsersController.cs @@ -29,6 +29,7 @@ using Umbraco.Web.Editors.Filters; using Umbraco.Web.Models; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Mvc; +using Umbraco.Web.Security; using Umbraco.Web.WebApi; using Umbraco.Web.WebApi.Filters; using Constants = Umbraco.Core.Constants; @@ -373,12 +374,6 @@ namespace Umbraco.Web.Editors throw new HttpResponseException(Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState)); } - if (EmailSender.CanSendRequiredEmail == false) - { - throw new HttpResponseException( - Request.CreateNotificationValidationErrorResponse("No Email server is configured")); - } - IUser user; if (Current.Configs.Settings().Security.UsernameIsEmail) { @@ -388,9 +383,17 @@ namespace Umbraco.Web.Editors else { //first validate the username if we're showing it - user = CheckUniqueUsername(userSave.Username, u => u.LastLoginDate != default(DateTime) || u.EmailConfirmedDate.HasValue); + user = CheckUniqueUsername(userSave.Username, u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue); + } + user = CheckUniqueEmail(userSave.Email, u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue); + + var userMgr = TryGetOwinContext().Result.GetBackOfficeUserManager(); + + if (!EmailSender.CanSendRequiredEmail && !userMgr.HasSendingUserInviteEventHandler) + { + throw new HttpResponseException( + Request.CreateNotificationValidationErrorResponse("No Email server is configured")); } - user = CheckUniqueEmail(userSave.Email, u => u.LastLoginDate != default(DateTime) || u.EmailConfirmedDate.HasValue); //Perform authorization here to see if the current user can actually save this user with the info being requested var authHelper = new UserEditorAuthorizationHelper(Services.ContentService, Services.MediaService, Services.UserService, Services.EntityService); @@ -424,16 +427,48 @@ namespace Umbraco.Web.Editors //ensure the invited date is set user.InvitedDate = DateTime.Now; - //Save the updated user + //Save the updated user (which will process the user groups too) Services.UserService.Save(user); var display = Mapper.Map(user); - //send the email + var inviteArgs = new UserInviteEventArgs( + Request.TryGetHttpContext().Result.GetCurrentRequestIpAddress(), + performingUser: Security.GetUserId().Result, + userSave, + user); - await SendUserInviteEmailAsync(display, Security.CurrentUser.Name, Security.CurrentUser.Email, user, userSave.Message); + try + { + userMgr.RaiseSendingUserInvite(inviteArgs); + } + catch (Exception ex) + { + Logger.Error(ex, "An error occured in a custom event handler while inviting the user"); + throw new HttpResponseException( + Request.CreateNotificationValidationErrorResponse($"An error occured inviting the user (check logs for more info): {ex.Message}")); + } + + // If the event is handled then no need to send the email + if (inviteArgs.InviteHandled) + { + // if no user result was created then map the minimum args manually for the UI + if (!inviteArgs.ShowUserResult) + { + display = new UserDisplay + { + Name = userSave.Name, + Email = userSave.Email, + Username = userSave.Username + }; + } + } + else + { + //send the email + await SendUserInviteEmailAsync(display, Security.CurrentUser.Name, Security.CurrentUser.Email, user, userSave.Message); + } display.AddSuccessNotification(Services.TextService.Localize("speechBubbles/resendInviteHeader"), Services.TextService.Localize("speechBubbles/resendInviteSuccess", new[] { user.Name })); - return display; } @@ -518,9 +553,9 @@ namespace Umbraco.Web.Editors /// /// [OutgoingEditorModelEvent] - public async Task PostSaveUser(UserSave userSave) + public UserDisplay PostSaveUser(UserSave userSave) { - if (userSave == null) throw new ArgumentNullException("userSave"); + if (userSave == null) throw new ArgumentNullException(nameof(userSave)); if (ModelState.IsValid == false) { @@ -545,6 +580,14 @@ namespace Umbraco.Web.Editors var hasErrors = false; + // we need to check if there's any Deny Local login providers present, if so we need to ensure that the user's email address cannot be changed + var owinContext = Request.TryGetOwinContext().Result; + var hasDenyLocalLogin = owinContext.Authentication.HasDenyLocalLogin(); + if (hasDenyLocalLogin) + { + userSave.Email = found.Email; // it cannot change, this would only happen if people are mucking around with the request + } + var existing = Services.UserService.GetByEmail(userSave.Email); if (existing != null && existing.Id != userSave.Id) { diff --git a/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs b/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs index 1d518fa1d3..7266e688c5 100644 --- a/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs +++ b/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs @@ -9,11 +9,14 @@ using Umbraco.Core.Composing; using Umbraco.Web.Editors; using Umbraco.Web.Features; using Umbraco.Web.Models; +using Umbraco.Core; namespace Umbraco.Web { using Core.Configuration; + using System; using Umbraco.Web.JavaScript; + using Umbraco.Web.Security; /// /// HtmlHelper extensions for the back office @@ -56,10 +59,9 @@ namespace Umbraco.Web /// /// /// - public static IHtmlString AngularValueExternalLoginInfoScript(this HtmlHelper html, IEnumerable externalLoginErrors) + public static IHtmlString AngularValueExternalLoginInfoScript(this HtmlHelper html, BackOfficeExternalLoginProviderErrors externalLoginErrors) { - var loginProviders = html.ViewContext.HttpContext.GetOwinContext().Authentication.GetExternalAuthenticationTypes() - .Where(p => p.Properties.ContainsKey("UmbracoBackOffice")) + var loginProviders = html.ViewContext.HttpContext.GetOwinContext().Authentication.GetBackOfficeExternalLoginProviders() .Select(p => new { authType = p.AuthenticationType, @@ -74,13 +76,15 @@ namespace Umbraco.Web if (externalLoginErrors != null) { - foreach (var error in externalLoginErrors) + foreach (var error in externalLoginErrors.Errors) { - sb.AppendFormat(@"errors.push(""{0}"");", error).AppendLine(); + sb.AppendFormat(@"errors.push(""{0}"");", error.ToSingleLine()).AppendLine(); } } sb.AppendLine(@"app.value(""externalLoginInfo"", {"); + if (externalLoginErrors?.AuthenticationType != null) + sb.AppendLine($@"errorProvider: '{externalLoginErrors.AuthenticationType}',"); sb.AppendLine(@"errors: errors,"); sb.Append(@"providers: "); sb.AppendLine(JsonConvert.SerializeObject(loginProviders)); @@ -89,6 +93,12 @@ namespace Umbraco.Web return html.Raw(sb.ToString()); } + [Obsolete("Use the other overload instead")] + public static IHtmlString AngularValueExternalLoginInfoScript(this HtmlHelper html, IEnumerable externalLoginErrors) + { + return html.AngularValueExternalLoginInfoScript(new BackOfficeExternalLoginProviderErrors(string.Empty, externalLoginErrors)); + } + /// /// Used to render the script that will pass in the angular "resetPasswordCodeInfo" service/value on page load /// diff --git a/src/Umbraco.Web/HttpCookieExtensions.cs b/src/Umbraco.Web/HttpCookieExtensions.cs index 5f520653f5..ebb77bd4a4 100644 --- a/src/Umbraco.Web/HttpCookieExtensions.cs +++ b/src/Umbraco.Web/HttpCookieExtensions.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Web; using Microsoft.Owin; +using Newtonsoft.Json; using Umbraco.Core; namespace Umbraco.Web @@ -61,11 +63,11 @@ namespace Umbraco.Web http.Request.Cookies.Remove(cookieName); //expire from the response - var angularCookie = http.Response.Cookies[cookieName]; - if (angularCookie != null) + var cookie = http.Response.Cookies[cookieName]; + if (cookie != null) { //this will expire immediately and be removed from the browser - angularCookie.Expires = DateTime.Now.AddYears(-1); + cookie.Expires = DateTime.Now.AddYears(-1); } else { diff --git a/src/Umbraco.Web/Logging/WebProfilerComponent.cs b/src/Umbraco.Web/Logging/WebProfilerComponent.cs index 1cb2142199..ff25eb6a22 100755 --- a/src/Umbraco.Web/Logging/WebProfilerComponent.cs +++ b/src/Umbraco.Web/Logging/WebProfilerComponent.cs @@ -10,7 +10,6 @@ namespace Umbraco.Web.Logging { private readonly WebProfiler _profiler; private readonly bool _profile; - private readonly List _terminate = new List(); public WebProfilerComponent(IProfiler profiler, ILogger logger) { @@ -39,21 +38,20 @@ namespace Umbraco.Web.Logging public void Terminate() { UmbracoApplicationBase.ApplicationInit -= InitializeApplication; - foreach (var t in _terminate) t(); } private void InitializeApplication(object sender, EventArgs args) { if (!(sender is HttpApplication app)) return; - // for *each* application (this will run more than once) - void beginRequest(object s, EventArgs a) => _profiler.UmbracoApplicationBeginRequest(s, a); - app.BeginRequest += beginRequest; - _terminate.Add(() => app.BeginRequest -= beginRequest); - - void endRequest(object s, EventArgs a) => _profiler.UmbracoApplicationEndRequest(s, a); - app.EndRequest += endRequest; - _terminate.Add(() => app.EndRequest -= endRequest); + // NOTE: We do not unbind these events ... because you just can't do that for HttpApplication events, they will + // be removed when the app dies. + app.BeginRequest += BeginRequest; + app.EndRequest += EndRequest; } + + private void BeginRequest(object s, EventArgs a) => _profiler.UmbracoApplicationBeginRequest(s, a); + + private void EndRequest(object s, EventArgs a) => _profiler.UmbracoApplicationEndRequest(s, a); } } diff --git a/src/Umbraco.Web/OwinExtensions.cs b/src/Umbraco.Web/OwinExtensions.cs index 6961f5c915..e7d41d113c 100644 --- a/src/Umbraco.Web/OwinExtensions.cs +++ b/src/Umbraco.Web/OwinExtensions.cs @@ -5,13 +5,28 @@ using Microsoft.Owin; using Microsoft.Owin.Security; using Umbraco.Core; using Umbraco.Core.Models.Identity; -using Umbraco.Core.Security; using Umbraco.Web.Security; namespace Umbraco.Web { public static class OwinExtensions { + /// + /// Used by external login providers to set any errors that occur during the OAuth negotiation + /// + /// + /// + public static void SetExternalLoginProviderErrors(this IOwinContext owinContext, BackOfficeExternalLoginProviderErrors errors) + => owinContext.Set(errors); + + /// + /// Retrieve any errors set by external login providers during OAuth negotiation + /// + /// + /// + internal static BackOfficeExternalLoginProviderErrors GetExternalLoginProviderErrors(this IOwinContext owinContext) + => owinContext.Get(); + /// /// Gets the for the Umbraco back office cookie /// diff --git a/src/Umbraco.Web/Security/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/AppBuilderExtensions.cs index 0a3e57c4fd..8f33f10eea 100644 --- a/src/Umbraco.Web/Security/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/AppBuilderExtensions.cs @@ -1,5 +1,8 @@ using System; using System.Threading; +using System.Web; +using System.Web.Mvc; +using System.Web.SessionState; using Microsoft.AspNet.Identity; using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin; @@ -266,7 +269,7 @@ namespace Umbraco.Web.Security { //Then our custom middlewares app.Use(typeof(ForceRenewalCookieAuthenticationMiddleware), app, options, Current.UmbracoContextAccessor); - app.Use(typeof(FixWindowsAuthMiddlware)); + app.Use(typeof(FixWindowsAuthMiddlware)); } //Marks all of the above middlewares to execute on Authenticate @@ -378,6 +381,19 @@ namespace Umbraco.Web.Security return app; } + /// + /// Enable the back office to detect and handle errors registered with external login providers + /// + /// + /// + /// + public static IAppBuilder UseUmbracoBackOfficeExternalLoginErrors(this IAppBuilder app, PipelineStage stage = PipelineStage.Authorize) + { + app.Use(typeof(BackOfficeExternalLoginProviderErrorMiddlware)); + app.UseStageMarker(stage); + return app; + } + public static void SanitizeThreadCulture(this IAppBuilder app) { Thread.CurrentThread.SanitizeThreadCulture(); diff --git a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs index 5da9c77d6b..78ff55ba73 100644 --- a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs @@ -1,9 +1,14 @@ using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNet.Identity; using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin.Security; +using Umbraco.Core; +using Constants = Umbraco.Core.Constants; namespace Umbraco.Web.Security { @@ -36,6 +41,36 @@ namespace Umbraco.Web.Security }; } + public static IEnumerable GetBackOfficeExternalLoginProviders(this IAuthenticationManager manager) + { + return manager.GetExternalAuthenticationTypes().Where(p => p.Properties.ContainsKey(Constants.Security.BackOfficeAuthenticationType)); + } + + /// + /// Returns the authentication type for the last registered external login (oauth) provider that specifies an auto-login redirect option + /// + /// + /// + public static string GetAutoLoginProvider(this IAuthenticationManager manager) + { + var found = manager.GetExternalAuthenticationTypes() + .LastOrDefault(p => p.Properties.ContainsKey(Constants.Security.BackOfficeAuthenticationType) + && p.Properties.TryGetValue(Constants.Security.BackOfficeExternalLoginOptionsProperty, out var options) + && options is BackOfficeExternalLoginProviderOptions externalLoginProviderOptions + && externalLoginProviderOptions.AutoRedirectLoginToExternalProvider); + + return found?.AuthenticationType; + } + + public static bool HasDenyLocalLogin(this IAuthenticationManager manager) + { + return manager.GetExternalAuthenticationTypes() + .Any(p => p.Properties.ContainsKey(Constants.Security.BackOfficeAuthenticationType) + && p.Properties.TryGetValue(Constants.Security.BackOfficeExternalLoginOptionsProperty, out var options) + && options is BackOfficeExternalLoginProviderOptions externalLoginProviderOptions + && externalLoginProviderOptions.DenyLocalLogin); + } + /// /// Extracts login info out of an external identity /// diff --git a/src/Umbraco.Web/Security/AuthenticationOptionsExtensions.cs b/src/Umbraco.Web/Security/AuthenticationOptionsExtensions.cs index 568bfc1a26..18f43b07e3 100644 --- a/src/Umbraco.Web/Security/AuthenticationOptionsExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationOptionsExtensions.cs @@ -9,53 +9,59 @@ namespace Umbraco.Web.Security { public static class AuthenticationOptionsExtensions { + // these are used for backwards compat + private const string ExternalSignInAutoLinkOptionsProperty = "ExternalSignInAutoLinkOptions"; + private const string ChallengeResultCallbackProperty = "ChallengeResultCallback"; /// - /// When trying to implement an Azure AD B2C provider or other OAuth provider that requires a customized Challenge Result in order to work then - /// this must be used. + /// Used to specify all back office external login options /// /// - /// - /// - /// See: http://issues.umbraco.org/issue/U4-7353 - /// + /// + public static void SetBackOfficeExternalLoginProviderOptions(this AuthenticationOptions authOptions, + BackOfficeExternalLoginProviderOptions externalLoginProviderOptions) + { + authOptions.Description.Properties[Constants.Security.BackOfficeExternalLoginOptionsProperty] = externalLoginProviderOptions; + + // for backwards compat, we need to add these: + if (externalLoginProviderOptions.AutoLinkOptions != null) + authOptions.Description.Properties[ExternalSignInAutoLinkOptionsProperty] = externalLoginProviderOptions.AutoLinkOptions; + if (externalLoginProviderOptions.OnChallenge != null) + authOptions.Description.Properties[ChallengeResultCallbackProperty] = externalLoginProviderOptions.OnChallenge; + } + + [Obsolete("Use SetBackOfficeExternalLoginProviderOptions instead")] public static void SetSignInChallengeResultCallback( this AuthenticationOptions authOptions, Func authProperties) { - authOptions.Description.Properties["ChallengeResultCallback"] = authProperties; + authOptions.Description.Properties[ChallengeResultCallbackProperty] = authProperties; } public static AuthenticationProperties GetSignInChallengeResult(this AuthenticationDescription authenticationDescription, IOwinContext ctx) { - if (authenticationDescription.Properties.ContainsKey("ChallengeResultCallback") == false) return null; - var cb = authenticationDescription.Properties["ChallengeResultCallback"] as Func; + if (authenticationDescription.Properties.ContainsKey(ChallengeResultCallbackProperty) == false) return null; + var cb = authenticationDescription.Properties[ChallengeResultCallbackProperty] as Func; if (cb == null) return null; return cb(ctx); } - /// - /// Used during the External authentication process to assign external sign-in options - /// that are used by the Umbraco authentication process. - /// - /// - /// + [Obsolete("Use SetBackOfficeExternalLoginProviderOptions instead")] public static void SetExternalSignInAutoLinkOptions( this AuthenticationOptions authOptions, ExternalSignInAutoLinkOptions options) { - authOptions.Description.Properties["ExternalSignInAutoLinkOptions"] = options; + authOptions.Description.Properties[ExternalSignInAutoLinkOptionsProperty] = options; } - /// - /// Used during the External authentication process to retrieve external sign-in options - /// that have been set with SetExternalAuthenticationOptions - /// - /// + [Obsolete("Use GetExternalSignInAutoLinkOptions instead")] public static ExternalSignInAutoLinkOptions GetExternalAuthenticationOptions(this AuthenticationDescription authenticationDescription) + => authenticationDescription.GetExternalSignInAutoLinkOptions(); + + public static ExternalSignInAutoLinkOptions GetExternalSignInAutoLinkOptions(this AuthenticationDescription authenticationDescription) { - if (authenticationDescription.Properties.ContainsKey("ExternalSignInAutoLinkOptions") == false) return null; - var options = authenticationDescription.Properties["ExternalSignInAutoLinkOptions"] as ExternalSignInAutoLinkOptions; + if (authenticationDescription.Properties.ContainsKey(ExternalSignInAutoLinkOptionsProperty) == false) return null; + var options = authenticationDescription.Properties[ExternalSignInAutoLinkOptionsProperty] as ExternalSignInAutoLinkOptions; return options; } @@ -88,7 +94,7 @@ namespace Umbraco.Web.Security options.Description.Properties["SocialIcon"] = icon; //flag for use in back office - options.Description.Properties["UmbracoBackOffice"] = true; + options.Description.Properties[Constants.Security.BackOfficeAuthenticationType] = true; if (callbackPath.IsNullOrWhiteSpace()) { diff --git a/src/Umbraco.Web/Security/BackOfficeClaimsIdentityFactory.cs b/src/Umbraco.Web/Security/BackOfficeClaimsIdentityFactory.cs index fbc24d2cd9..d61d2ea711 100644 --- a/src/Umbraco.Web/Security/BackOfficeClaimsIdentityFactory.cs +++ b/src/Umbraco.Web/Security/BackOfficeClaimsIdentityFactory.cs @@ -26,7 +26,13 @@ namespace Umbraco.Web.Security public override async Task CreateAsync(UserManager manager, T user, string authenticationType) { var baseIdentity = await base.CreateAsync(manager, user, authenticationType); - + + // now we can flow any custom claims that the actual user has currently assigned which could be done in the OnExternalLogin callback + foreach (var claim in user.Claims) + { + baseIdentity.AddClaim(new Claim(claim.ClaimType, claim.ClaimValue)); + } + var umbracoIdentity = new UmbracoBackOfficeIdentity(baseIdentity, user.Id, user.UserName, diff --git a/src/Umbraco.Web/Security/BackOfficeExternalLoginProviderErrorMiddlware.cs b/src/Umbraco.Web/Security/BackOfficeExternalLoginProviderErrorMiddlware.cs new file mode 100644 index 0000000000..6e6477443b --- /dev/null +++ b/src/Umbraco.Web/Security/BackOfficeExternalLoginProviderErrorMiddlware.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.Web.Mvc; +using Microsoft.Owin; +using Newtonsoft.Json; +using Umbraco.Core; + +namespace Umbraco.Web.Security +{ + /// + /// Used to handle errors registered by external login providers + /// + /// + /// When an external login provider registers an error with during the OAuth process, + /// this middleware will detect that, store the errors into cookie data and redirect to the back office login so we can read the errors back out. + /// + internal class BackOfficeExternalLoginProviderErrorMiddlware : OwinMiddleware + { + public BackOfficeExternalLoginProviderErrorMiddlware(OwinMiddleware next) : base(next) + { + } + + public override async Task Invoke(IOwinContext context) + { + var shortCircuit = false; + if (!context.Request.Uri.IsClientSideRequest()) + { + // check if we have any errors registered + var errors = context.GetExternalLoginProviderErrors(); + if (errors != null) + { + shortCircuit = true; + + var serialized = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(errors))); + + context.Response.Cookies.Append(ViewDataExtensions.TokenExternalSignInError, serialized, new CookieOptions + { + Expires = DateTime.Now.AddMinutes(5), + HttpOnly = true, + Secure = context.Request.IsSecure + }); + + context.Response.Redirect(context.Request.Uri.ToString()); + } + } + + if (Next != null && !shortCircuit) + { + await Next.Invoke(context); + } + } + } +} diff --git a/src/Umbraco.Web/Security/BackOfficeExternalLoginProviderErrors.cs b/src/Umbraco.Web/Security/BackOfficeExternalLoginProviderErrors.cs new file mode 100644 index 0000000000..39b967fa96 --- /dev/null +++ b/src/Umbraco.Web/Security/BackOfficeExternalLoginProviderErrors.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Umbraco.Web.Security +{ + public class BackOfficeExternalLoginProviderErrors + { + // required for deserialization + public BackOfficeExternalLoginProviderErrors() + { + } + + public BackOfficeExternalLoginProviderErrors(string authenticationType, IEnumerable errors) + { + AuthenticationType = authenticationType; + Errors = errors ?? Enumerable.Empty(); + } + + public string AuthenticationType { get; set; } + public IEnumerable Errors { get; set; } + } +} diff --git a/src/Umbraco.Web/Security/BackOfficeExternalLoginProviderOptions.cs b/src/Umbraco.Web/Security/BackOfficeExternalLoginProviderOptions.cs new file mode 100644 index 0000000000..4ef527460e --- /dev/null +++ b/src/Umbraco.Web/Security/BackOfficeExternalLoginProviderOptions.cs @@ -0,0 +1,53 @@ +using Microsoft.Owin; +using Microsoft.Owin.Security; +using System; +using System.Collections; +using System.Runtime.Serialization; + +namespace Umbraco.Web.Security +{ + + /// + /// Options used to configure back office external login providers + /// + public class BackOfficeExternalLoginProviderOptions + { + /// + /// When specified this will be called to retrieve the used during the authentication Challenge response. + /// + /// + /// This will generally not be needed since OpenIdConnect.RedirectToIdentityProvider options should be used instead + /// + [IgnoreDataMember] + public Func OnChallenge { get; set; } + + /// + /// Options used to control how users can be auto-linked/created/updated based on the external login provider + /// + [IgnoreDataMember] // we are ignoring this one from serialization for backwards compat since these options are manually incuded in the response separately + public ExternalSignInAutoLinkOptions AutoLinkOptions { get; set; } = new ExternalSignInAutoLinkOptions(); + + /// + /// When set to true will disable all local user login functionality + /// + public bool DenyLocalLogin { get; set; } + + /// + /// When specified this will automatically redirect to the OAuth login provider instead of prompting the user to click on the OAuth button first. + /// + /// + /// This is generally used in conjunction with . If more than one OAuth provider specifies this, the last registered + /// provider's redirect settings will win. + /// + public bool AutoRedirectLoginToExternalProvider { get; set; } + + /// + /// A virtual path to a custom angular view that is used to replace the entire UI that renders the external login button that the user interacts with + /// + /// + /// If this view is specified it is 100% up to the user to render the html responsible for rendering the link/un-link buttons along with showing any errors + /// that occur. This overrides what Umbraco normally does by default. + /// + public string CustomBackOfficeView { get; set; } + } +} diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager.cs b/src/Umbraco.Web/Security/BackOfficeUserManager.cs index 6205c1705c..f91c128cde 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager.cs @@ -610,9 +610,11 @@ namespace Umbraco.Web.Security OnLoginSuccess(new IdentityAuditEventArgs(AuditEvent.LoginSucces, GetCurrentRequestIpAddress(), affectedUser: userId)); } - internal void RaiseLogoutSuccessEvent(int userId) + internal SignOutAuditEventArgs RaiseLogoutSuccessEvent(int userId) { - OnLogoutSuccess(new IdentityAuditEventArgs(AuditEvent.LogoutSuccess, GetCurrentRequestIpAddress(), affectedUser: userId)); + var args = new SignOutAuditEventArgs(AuditEvent.LogoutSuccess, GetCurrentRequestIpAddress(), affectedUser: userId); + OnLogoutSuccess(args); + return args; } internal void RaisePasswordChangedEvent(int userId) @@ -631,6 +633,10 @@ namespace Umbraco.Web.Security OnResetAccessFailedCount(new IdentityAuditEventArgs(AuditEvent.ResetAccessFailedCount, GetCurrentRequestIpAddress(), affectedUser: userId)); } + internal void RaiseSendingUserInvite(UserInviteEventArgs args) => OnSendingUserInvite(args); + internal bool HasSendingUserInviteEventHandler => SendingUserInvite != null; + + // TODO: Not sure why these are not strongly typed events?? They should be in netcore! public static event EventHandler AccountLocked; public static event EventHandler AccountUnlocked; public static event EventHandler ForgotPasswordRequested; @@ -643,60 +649,34 @@ namespace Umbraco.Web.Security public static event EventHandler PasswordReset; public static event EventHandler ResetAccessFailedCount; - protected virtual void OnAccountLocked(IdentityAuditEventArgs e) - { - if (AccountLocked != null) AccountLocked(this, e); - } + /// + /// Raised when a user is invited + /// + public static event EventHandler SendingUserInvite; // this event really has nothing to do with the user manager but was the most convenient place to put it - protected virtual void OnAccountUnlocked(IdentityAuditEventArgs e) - { - if (AccountUnlocked != null) AccountUnlocked(this, e); - } + protected virtual void OnSendingUserInvite(UserInviteEventArgs e) => SendingUserInvite?.Invoke(this, e); - protected virtual void OnForgotPasswordRequested(IdentityAuditEventArgs e) - { - if (ForgotPasswordRequested != null) ForgotPasswordRequested(this, e); - } + protected virtual void OnAccountLocked(IdentityAuditEventArgs e) => AccountLocked?.Invoke(this, e); - protected virtual void OnForgotPasswordChangedSuccess(IdentityAuditEventArgs e) - { - if (ForgotPasswordChangedSuccess != null) ForgotPasswordChangedSuccess(this, e); - } + protected virtual void OnAccountUnlocked(IdentityAuditEventArgs e) => AccountUnlocked?.Invoke(this, e); - protected virtual void OnLoginFailed(IdentityAuditEventArgs e) - { - if (LoginFailed != null) LoginFailed(this, e); - } + protected virtual void OnForgotPasswordRequested(IdentityAuditEventArgs e) => ForgotPasswordRequested?.Invoke(this, e); - protected virtual void OnLoginRequiresVerification(IdentityAuditEventArgs e) - { - if (LoginRequiresVerification != null) LoginRequiresVerification(this, e); - } + protected virtual void OnForgotPasswordChangedSuccess(IdentityAuditEventArgs e) => ForgotPasswordChangedSuccess?.Invoke(this, e); - protected virtual void OnLoginSuccess(IdentityAuditEventArgs e) - { - if (LoginSuccess != null) LoginSuccess(this, e); - } + protected virtual void OnLoginFailed(IdentityAuditEventArgs e) => LoginFailed?.Invoke(this, e); - protected virtual void OnLogoutSuccess(IdentityAuditEventArgs e) - { - if (LogoutSuccess != null) LogoutSuccess(this, e); - } + protected virtual void OnLoginRequiresVerification(IdentityAuditEventArgs e) => LoginRequiresVerification?.Invoke(this, e); - protected virtual void OnPasswordChanged(IdentityAuditEventArgs e) - { - if (PasswordChanged != null) PasswordChanged(this, e); - } + protected virtual void OnLoginSuccess(IdentityAuditEventArgs e) => LoginSuccess?.Invoke(this, e); - protected virtual void OnPasswordReset(IdentityAuditEventArgs e) - { - if (PasswordReset != null) PasswordReset(this, e); - } + protected virtual void OnLogoutSuccess(IdentityAuditEventArgs e) => LogoutSuccess?.Invoke(this, e); - protected virtual void OnResetAccessFailedCount(IdentityAuditEventArgs e) - { - if (ResetAccessFailedCount != null) ResetAccessFailedCount(this, e); - } + protected virtual void OnPasswordChanged(IdentityAuditEventArgs e) => PasswordChanged?.Invoke(this, e); + + protected virtual void OnPasswordReset(IdentityAuditEventArgs e) => PasswordReset?.Invoke(this, e); + + protected virtual void OnResetAccessFailedCount(IdentityAuditEventArgs e) => ResetAccessFailedCount?.Invoke(this, e); /// /// Returns the current request IP address for logging if there is one diff --git a/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs index 2a9c3a5f07..52f758aa74 100644 --- a/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs +++ b/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.Serialization; using Microsoft.AspNet.Identity.Owin; using Umbraco.Core; using Umbraco.Core.Composing; @@ -7,6 +8,7 @@ using Umbraco.Core.Models.Identity; namespace Umbraco.Web.Security { + /// /// Options used to configure auto-linking external OAuth providers /// @@ -30,15 +32,23 @@ namespace Umbraco.Web.Security private readonly string[] _defaultUserGroups; + /// + /// 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; set; } = true; + /// /// 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; } @@ -48,7 +58,7 @@ namespace Umbraco.Web.Security /// /// /// - public string[] GetDefaultUserGroups(UmbracoContext umbracoContext, ExternalLoginInfo loginInfo) + public virtual string[] GetDefaultUserGroups(UmbracoContext umbracoContext, ExternalLoginInfo loginInfo) { return _defaultUserGroups; } @@ -61,7 +71,7 @@ namespace Umbraco.Web.Security /// /// For public auth providers this should always be false!!! /// - public bool ShouldAutoLinkExternalAccount(UmbracoContext umbracoContext, ExternalLoginInfo loginInfo) + public virtual bool ShouldAutoLinkExternalAccount(UmbracoContext umbracoContext, ExternalLoginInfo loginInfo) { return _autoLinkExternalAccount; } @@ -71,7 +81,7 @@ namespace Umbraco.Web.Security /// /// The default Culture to use for auto-linking users /// - public string GetDefaultCulture(UmbracoContext umbracoContext, ExternalLoginInfo loginInfo) + public virtual string GetDefaultCulture(UmbracoContext umbracoContext, ExternalLoginInfo loginInfo) { return _defaultCulture; } diff --git a/src/Umbraco.Web/Security/FixWindowsAuthMiddlware.cs b/src/Umbraco.Web/Security/FixWindowsAuthMiddlware.cs index 30038e1f31..3338344e73 100644 --- a/src/Umbraco.Web/Security/FixWindowsAuthMiddlware.cs +++ b/src/Umbraco.Web/Security/FixWindowsAuthMiddlware.cs @@ -8,6 +8,7 @@ using Umbraco.Core.Security; namespace Umbraco.Web.Security { + /// /// This is used to inspect the request to see if 2 x identities are assigned: A windows one and a back office one. /// When this is the case, it means that auth has executed for Windows & auth has executed for our back office cookie diff --git a/src/Umbraco.Web/Security/IdentityAuditEventArgs.cs b/src/Umbraco.Web/Security/IdentityAuditEventArgs.cs index 81407afe50..23e570dd40 100644 --- a/src/Umbraco.Web/Security/IdentityAuditEventArgs.cs +++ b/src/Umbraco.Web/Security/IdentityAuditEventArgs.cs @@ -4,6 +4,7 @@ using Umbraco.Core.Security; namespace Umbraco.Web.Security { + /// /// This class is used by events raised from the BackofficeUserManager /// @@ -126,6 +127,7 @@ namespace Umbraco.Web.Security LogoutSuccess, PasswordChanged, PasswordReset, - ResetAccessFailedCount + ResetAccessFailedCount, + SendingUserInvite } } diff --git a/src/Umbraco.Web/Security/SignOutAuditEventArgs.cs b/src/Umbraco.Web/Security/SignOutAuditEventArgs.cs new file mode 100644 index 0000000000..e7943f70b6 --- /dev/null +++ b/src/Umbraco.Web/Security/SignOutAuditEventArgs.cs @@ -0,0 +1,19 @@ +namespace Umbraco.Web.Security +{ + /// + /// Event args used when signing out + /// + public class SignOutAuditEventArgs : IdentityAuditEventArgs + { + public SignOutAuditEventArgs(AuditEvent action, string ipAddress, string comment = null, int performingUser = -1, int affectedUser = -1) + : base(action, ipAddress, comment, performingUser, affectedUser) + { + } + + /// + /// Allows event handlers to set a GET absolute URL to be redirected to after successful logout out of the back office. This + /// can be used for external login providers. + /// + public string SignOutRedirectUrl { get; set; } + } +} diff --git a/src/Umbraco.Web/Security/UserInviteEventArgs.cs b/src/Umbraco.Web/Security/UserInviteEventArgs.cs new file mode 100644 index 0000000000..9fb53a44c0 --- /dev/null +++ b/src/Umbraco.Web/Security/UserInviteEventArgs.cs @@ -0,0 +1,36 @@ +using Umbraco.Core.Models.Membership; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.Web.Security +{ + public class UserInviteEventArgs : IdentityAuditEventArgs + { + public UserInviteEventArgs(string ipAddress, int performingUser, UserInvite invitedUser, IUser localUser, string comment = null) + : base(AuditEvent.SendingUserInvite, ipAddress, comment, performingUser) + { + InvitedUser = invitedUser ?? throw new System.ArgumentNullException(nameof(invitedUser)); + User = localUser; + } + + /// + /// The model used to invite the user + /// + public UserInvite InvitedUser { get; } + + /// + /// If event handler sets this to true it indicates that Umbraco will no try to send the invite itself + /// + public bool InviteHandled { get; set; } + + /// + /// The local user that has been created that is pending the invite + /// + public IUser User { get; } + + /// + /// if set to true will show the edit user button in the UI, else it will not be shown + /// + public bool ShowUserResult { get; set; } + + } +} diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index e93caaac66..170a8ad8a7 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -165,8 +165,10 @@ + + @@ -299,6 +301,11 @@ + + + + + diff --git a/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs b/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs index 0633cca3a0..9a9f5e7e01 100644 --- a/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs +++ b/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs @@ -68,6 +68,9 @@ namespace Umbraco.Web // Configure OWIN for authentication. ConfigureUmbracoAuthentication(app); + // must come after all authentication + app.UseUmbracoBackOfficeExternalLoginErrors(); + app .UseSignalR(GlobalSettings) .FinalizeMiddlewareConfiguration(); @@ -99,6 +102,7 @@ namespace Umbraco.Web app .UseUmbracoBackOfficeCookieAuthentication(UmbracoContextAccessor, RuntimeState, Services.UserService, GlobalSettings, UmbracoSettings.Security, PipelineStage.Authenticate) .UseUmbracoBackOfficeExternalCookieAuthentication(UmbracoContextAccessor, RuntimeState, GlobalSettings, PipelineStage.Authenticate) + // TODO: this would be considered a breaking change but this must come after all authentication so should be moved within ConfigureMiddleware .UseUmbracoPreviewAuthentication(UmbracoContextAccessor, RuntimeState, GlobalSettings, UmbracoSettings.Security, PipelineStage.Authorize); } diff --git a/src/Umbraco.Web/ViewDataExtensions.cs b/src/Umbraco.Web/ViewDataExtensions.cs index e47171f1ba..ac4f4cdf75 100644 --- a/src/Umbraco.Web/ViewDataExtensions.cs +++ b/src/Umbraco.Web/ViewDataExtensions.cs @@ -1,5 +1,12 @@ -using System.Collections.Generic; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Web; using System.Web.Mvc; +using Umbraco.Core; +using Umbraco.Web.Security; namespace Umbraco.Web { @@ -18,6 +25,49 @@ namespace Umbraco.Web return true; } + /// + /// Copies data from a request cookie to view data and then clears the cookie in the response + /// + /// + /// + /// + /// + /// + /// + /// This is similar to TempData but in some cases we cannot use TempData which relies on the temp data provider and session. + /// The cookie value can either be a simple string value + /// + /// + internal static bool FromBase64CookieData(this ViewDataDictionary viewData, HttpContextBase httpContext, string cookieName) + { + var hasCookie = httpContext.Request.HasCookie(cookieName); + if (!hasCookie) return false; + + // get the cookie value + var cookieVal = httpContext.Request.GetCookieValue(cookieName); + + if (cookieVal == null) + return false; + + // ensure the cookie is expired (must be done after reading the value) + httpContext.ExpireCookie(cookieName); + + if (cookieVal.IsNullOrWhiteSpace()) + return false; + + try + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(System.Net.WebUtility.UrlDecode(cookieVal))); + // deserialize to T and store in viewdata + viewData[cookieName] = JsonConvert.DeserializeObject(decoded); + return true; + } + catch (Exception) + { + return false; + } + } + public static string GetUmbracoPath(this ViewDataDictionary viewData) { return (string)viewData[TokenUmbracoPath]; @@ -48,14 +98,37 @@ namespace Umbraco.Web viewData[TokenUmbracoBaseFolder] = value; } - public static IEnumerable GetExternalSignInError(this ViewDataDictionary viewData) + /// + /// Used by the back office login screen to get any registered external login provider errors + /// + /// + /// + public static BackOfficeExternalLoginProviderErrors GetExternalSignInProviderErrors(this ViewDataDictionary viewData) { - return (IEnumerable)viewData[TokenExternalSignInError]; + return (BackOfficeExternalLoginProviderErrors)viewData[TokenExternalSignInError]; } + [Obsolete("Use GetExternalSignInProviderErrors instead")] + public static IEnumerable GetExternalSignInError(this ViewDataDictionary viewData) + { + var errs = viewData.GetExternalSignInProviderErrors(); + return errs?.Errors ?? Enumerable.Empty(); + } + + /// + /// Used by the back office controller to register any external login provider errors + /// + /// + /// + public static void SetExternalSignInProviderErrors(this ViewDataDictionary viewData, BackOfficeExternalLoginProviderErrors errors) + { + viewData[TokenExternalSignInError] = errors; + } + + [Obsolete("Use SetExternalSignInProviderErrors instead")] public static void SetExternalSignInError(this ViewDataDictionary viewData, IEnumerable value) { - viewData[TokenExternalSignInError] = value; + viewData[TokenExternalSignInError] = new BackOfficeExternalLoginProviderErrors(string.Empty, value); } public static string GetPasswordResetCode(this ViewDataDictionary viewData)