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 03ba58d15e..723675ca1a 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; namespace Umbraco.Core.Migrations.Upgrade { @@ -193,6 +194,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}"); //FINAL } 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 b7b9867618..71b2a7be4a 100755 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -132,6 +132,7 @@ + @@ -143,6 +144,8 @@ + + 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 004945bb46..6296a8203f 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -167,6 +167,7 @@ + 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: " 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/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/views/common/overlays/user/user.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.controller.js index 115e2dd6a3..669bd711d3 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.controller.js @@ -1,5 +1,8 @@ angular.module("umbraco") - .controller("Umbraco.Overlays.UserController", function ($scope, $location, $timeout, dashboardResource, userService, historyService, eventsService, externalLoginInfo, authResource, currentUserResource, formHelper, localizationService) { + .controller("Umbraco.Overlays.UserController", function ($scope, $location, $timeout, + dashboardResource, userService, historyService, eventsService, + externalLoginInfo, externalLoginInfoService, authResource, + currentUserResource, formHelper, localizationService) { $scope.history = historyService.getCurrent(); //$scope.version = Umbraco.Sys.ServerVariables.application.version + " assembly: " + Umbraco.Sys.ServerVariables.application.assemblyVersion; @@ -14,7 +17,12 @@ angular.module("umbraco") }); } */ - $scope.externalLoginProviders = externalLoginInfo.providers; + + // Set flag if any have deny local login, in which case we must disable all password functionality + $scope.denyLocalLogin = externalLoginInfoService.hasDenyLocalLogin(); + // Only include login providers that have editable options + $scope.externalLoginProviders = externalLoginInfoService.getLoginProvidersWithOptions(); + $scope.externalLinkLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLinkLoginsUrl; var evts = []; evts.push(eventsService.on("historyService.add", function (e, args) { @@ -72,10 +80,9 @@ angular.module("umbraco") //updateTimeout(); authResource.getCurrentUserLinkedLogins().then(function(logins) { + //reset all to be un-linked - for (var provider in $scope.externalLoginProviders) { - $scope.externalLoginProviders[provider].linkedProviderKey = undefined; - } + $scope.externalLoginProviders.forEach(provider => provider.linkedProviderKey = undefined); //set the linked logins for (var login in logins) { diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html index 3108fe9ac7..fdd2671200 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html @@ -21,6 +21,7 @@ -
- - +
+ + - - - + + @@ -86,7 +92,8 @@ -
+ +
Change password diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-login.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-login.html index 9787c57940..e13e8df1d2 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-login.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-login.html @@ -8,7 +8,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/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 c3eba87d6f..1b48b9ca0d 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -159,8 +159,10 @@ + + @@ -283,6 +285,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)