diff --git a/LICENSE.md b/LICENSE.md index 149435b7cb..c5560c3ce1 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,9 +1,9 @@ -# The MIT License (MIT) # - -Copyright (c) 2013 Umbraco - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# The MIT License (MIT) # + +Copyright (c) 2013 Umbraco + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/build/Build.proj b/build/Build.proj index 5be460d09b..264f71a5d5 100644 --- a/build/Build.proj +++ b/build/Build.proj @@ -266,6 +266,7 @@ + $(BUILD_RELEASE) diff --git a/build/NuSpecs/UmbracoExamine.PDF.nuspec b/build/NuSpecs/UmbracoExamine.PDF.nuspec deleted file mode 100644 index 5d1afff2b5..0000000000 --- a/build/NuSpecs/UmbracoExamine.PDF.nuspec +++ /dev/null @@ -1,23 +0,0 @@ - - - - UmbracoExamine.PDF - 0.7.0 - Umbraco HQ - Umbraco HQ - http://opensource.org/licenses/MIT - http://umbraco.com/ - http://umbraco.com/media/357769/100px_transparent.png - false - UmbracoExmine.PDF - umbraco - - - - - - - - - - \ No newline at end of file diff --git a/src/SQLCE4Umbraco/SqlCE4Umbraco.csproj b/src/SQLCE4Umbraco/SqlCE4Umbraco.csproj index b7d3f1f655..73983e7e30 100644 --- a/src/SQLCE4Umbraco/SqlCE4Umbraco.csproj +++ b/src/SQLCE4Umbraco/SqlCE4Umbraco.csproj @@ -77,8 +77,9 @@ - - + + Designer + diff --git a/src/SQLCE4Umbraco/app.config b/src/SQLCE4Umbraco/app.config index 8f828418f3..1f5a6442ad 100644 --- a/src/SQLCE4Umbraco/app.config +++ b/src/SQLCE4Umbraco/app.config @@ -14,6 +14,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SolutionInfo.cs b/src/SolutionInfo.cs index 9fd1385311..6c766f8e97 100644 --- a/src/SolutionInfo.cs +++ b/src/SolutionInfo.cs @@ -11,5 +11,5 @@ using System.Resources; [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyFileVersion("7.2.5")] -[assembly: AssemblyInformationalVersion("7.2.5")] \ No newline at end of file +[assembly: AssemblyFileVersion("7.3.0")] +[assembly: AssemblyInformationalVersion("7.3.0")] \ No newline at end of file diff --git a/src/Umbraco.Core/Constants-Web.cs b/src/Umbraco.Core/Constants-Web.cs index 83cb995eeb..a936b2e388 100644 --- a/src/Umbraco.Core/Constants-Web.cs +++ b/src/Umbraco.Core/Constants-Web.cs @@ -18,5 +18,30 @@ public const string AuthCookieName = "UMB_UCONTEXT"; } + + public static class Security + { + + public const string BackOfficeAuthenticationType = "UmbracoBackOffice"; + public const string BackOfficeExternalAuthenticationType = "UmbracoExternalCookie"; + public const string BackOfficeExternalCookieName = "UMB_EXTLOGIN"; + + /// + /// The prefix used for external identity providers for their authentication type + /// + /// + /// By default we don't want to interfere with front-end external providers and their default setup, for back office the + /// providers need to be setup differently and each auth type for the back office will be prefixed with this value + /// + public const string BackOfficeExternalAuthenticationTypePrefix = "Umbraco."; + + public const string StartContentNodeIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/startcontentnode"; + public const string StartMediaNodeIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/startmedianode"; + public const string AllowedApplicationsClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/allowedapps"; + //public const string UserIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/userid"; + public const string CultureClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/culture"; + public const string SessionIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/sessionid"; + + } } } \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs new file mode 100644 index 0000000000..1523cf9040 --- /dev/null +++ b/src/Umbraco.Core/Models/Identity/BackOfficeIdentityUser.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNet.Identity; +using Umbraco.Core.Security; + +namespace Umbraco.Core.Models.Identity +{ + public class BackOfficeIdentityUser : IdentityUser, IdentityUserClaim> + { + + public virtual async Task GenerateUserIdentityAsync(BackOfficeUserManager manager) + { + // NOTE the authenticationType must match the umbraco one + // defined in CookieAuthenticationOptions.AuthenticationType + var userIdentity = await manager.CreateIdentityAsync(this, Constants.Security.BackOfficeAuthenticationType); + return userIdentity; + } + + /// + /// Gets/sets the user's real name + /// + public string Name { get; set; } + public int StartContentId { get; set; } + public int StartMediaId { get; set; } + public string[] AllowedSections { get; set; } + public string Culture { get; set; } + + public string UserTypeAlias { get; set; } + + /// + /// Overridden to make the retrieval lazy + /// + public override ICollection Logins + { + get + { + if (_getLogins != null && _getLogins.IsValueCreated == false) + { + _logins = new ObservableCollection(); + foreach (var l in _getLogins.Value) + { + _logins.Add(l); + } + //now assign events + _logins.CollectionChanged += Logins_CollectionChanged; + } + return _logins; + } + } + + public bool LoginsChanged { get; private set; } + + void Logins_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + LoginsChanged = true; + } + + private ObservableCollection _logins; + private Lazy> _getLogins; + + /// + /// Used to set a lazy call back to populate the user's Login list + /// + /// + public void SetLoginsCallback(Lazy> callback) + { + if (callback == null) throw new ArgumentNullException("callback"); + _getLogins = callback; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs b/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs new file mode 100644 index 0000000000..c95722f4e3 --- /dev/null +++ b/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs @@ -0,0 +1,25 @@ +using Umbraco.Core.Models.EntityBase; + +namespace Umbraco.Core.Models.Identity +{ + public interface IIdentityUserLogin : IAggregateRoot, IRememberBeingDirty, ICanBeDirty + { + /// + /// 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; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Identity/IdentityModelMappings.cs b/src/Umbraco.Core/Models/Identity/IdentityModelMappings.cs new file mode 100644 index 0000000000..def71a8982 --- /dev/null +++ b/src/Umbraco.Core/Models/Identity/IdentityModelMappings.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using AutoMapper; + +using Umbraco.Core.Models.Mapping; +using Umbraco.Core.Models.Membership; + +namespace Umbraco.Core.Models.Identity +{ + public class IdentityModelMappings : MapperConfiguration + { + public override void ConfigureMappings(IConfiguration config, ApplicationContext applicationContext) + { + config.CreateMap() + .ForMember(user => user.Email, expression => expression.MapFrom(user => user.Email)) + .ForMember(user => user.Id, expression => expression.MapFrom(user => user.Id)) + .ForMember(user => user.LockoutEnabled, expression => expression.MapFrom(user => user.IsLockedOut)) + .ForMember(user => user.LockoutEndDateUtc, expression => expression.UseValue(DateTime.MaxValue.ToUniversalTime())) + .ForMember(user => user.UserName, expression => expression.MapFrom(user => user.Username)) + .ForMember(user => user.PasswordHash, expression => expression.MapFrom(user => GetPasswordHash(user.RawPasswordValue))) + .ForMember(user => user.Culture, expression => expression.MapFrom(user => user.GetUserCulture(applicationContext.Services.TextService))) + .ForMember(user => user.Name, expression => expression.MapFrom(user => user.Name)) + .ForMember(user => user.StartMediaId, expression => expression.MapFrom(user => user.StartMediaId)) + .ForMember(user => user.StartContentId, expression => expression.MapFrom(user => user.StartContentId)) + .ForMember(user => user.UserTypeAlias, expression => expression.MapFrom(user => user.UserType.Alias)) + .ForMember(user => user.AllowedSections, expression => expression.MapFrom(user => user.AllowedSections.ToArray())); + } + + private string GetPasswordHash(string storedPass) + { + return storedPass.StartsWith("___UIDEMPTYPWORD__") ? null : storedPass; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Identity/IdentityUser.cs b/src/Umbraco.Core/Models/Identity/IdentityUser.cs new file mode 100644 index 0000000000..cba4fc514a --- /dev/null +++ b/src/Umbraco.Core/Models/Identity/IdentityUser.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNet.Identity; + +namespace Umbraco.Core.Models.Identity +{ + /// + /// Default IUser implementation + /// + /// + /// + /// This class normally exists inside of the EntityFramework library, not sure why MS chose to explicitly put it there but we don't want + /// references to that so we will create our own here + /// + public class IdentityUser : IUser + where TLogin : IIdentityUserLogin + //NOTE: Making our role id a string + where TRole : IdentityUserRole + where TClaim : IdentityUserClaim + { + + /// + /// Constructor + /// + /// + public IdentityUser() + { + this.Claims = new List(); + this.Roles = new List(); + this.Logins = new List(); + } + + /// + /// Email + /// + /// + public virtual string Email { get; set; } + + /// + /// True if the email is confirmed, default is false + /// + /// + public virtual bool EmailConfirmed { get; set; } + + /// + /// The salted/hashed form of the user password + /// + /// + public virtual string PasswordHash { get; set; } + + /// + /// A random value that should change whenever a users credentials have changed (password changed, login removed) + /// + /// + public virtual string SecurityStamp { get; set; } + + /// + /// PhoneNumber for the user + /// + /// + public virtual string PhoneNumber { get; set; } + + /// + /// True if the phone number is confirmed, default is false + /// + /// + public virtual bool PhoneNumberConfirmed { get; set; } + + /// + /// Is two factor enabled for the user + /// + /// + public virtual bool TwoFactorEnabled { get; set; } + + /// + /// DateTime in UTC when lockout ends, any time in the past is considered not locked out. + /// + /// + public virtual DateTime? LockoutEndDateUtc { get; set; } + + /// + /// Is lockout enabled for this user + /// + /// + public virtual bool LockoutEnabled { get; set; } + + /// + /// Used to record failures for the purposes of lockout + /// + /// + public virtual int AccessFailedCount { get; set; } + + /// + /// Navigation property for user roles + /// + /// + public virtual ICollection Roles { get; private set; } + + /// + /// Navigation property for user claims + /// + /// + public virtual ICollection Claims { get; private set; } + + /// + /// Navigation property for user logins + /// + /// + public virtual ICollection Logins { get; private set; } + + /// + /// User ID (Primary Key) + /// + /// + public virtual TKey Id { get; set; } + + /// + /// User name + /// + /// + public virtual string UserName { get; set; } + + + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Identity/IdentityUserClaim.cs b/src/Umbraco.Core/Models/Identity/IdentityUserClaim.cs new file mode 100644 index 0000000000..832438f3c3 --- /dev/null +++ b/src/Umbraco.Core/Models/Identity/IdentityUserClaim.cs @@ -0,0 +1,38 @@ +namespace Umbraco.Core.Models.Identity +{ + /// + /// EntityType that represents one specific user claim + /// + /// + /// + /// + /// This class normally exists inside of the EntityFramework library, not sure why MS chose to explicitly put it there but we don't want + /// references to that so we will create our own here + /// + public class IdentityUserClaim + { + /// + /// Primary key + /// + /// + public virtual int Id { get; set; } + + /// + /// User Id for the user who owns this login + /// + /// + public virtual TKey UserId { get; set; } + + /// + /// Claim type + /// + /// + public virtual string ClaimType { get; set; } + + /// + /// Claim value + /// + /// + public virtual string ClaimValue { get; set; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs b/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs new file mode 100644 index 0000000000..efed4ce37c --- /dev/null +++ b/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs @@ -0,0 +1,46 @@ +using System; +using Umbraco.Core.Models.EntityBase; + +namespace Umbraco.Core.Models.Identity +{ + /// + /// Entity type for a user's login (i.e. facebook, google) + /// + /// + public class IdentityUserLogin : Entity, IIdentityUserLogin + { + public IdentityUserLogin(string loginProvider, string providerKey, int userId) + { + LoginProvider = loginProvider; + ProviderKey = providerKey; + UserId = userId; + } + + public IdentityUserLogin(int id, string loginProvider, string providerKey, int userId, DateTime createDate) + { + Id = id; + LoginProvider = loginProvider; + ProviderKey = providerKey; + UserId = userId; + 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; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Identity/IdentityUserRole.cs b/src/Umbraco.Core/Models/Identity/IdentityUserRole.cs new file mode 100644 index 0000000000..5c68a97d86 --- /dev/null +++ b/src/Umbraco.Core/Models/Identity/IdentityUserRole.cs @@ -0,0 +1,26 @@ +namespace Umbraco.Core.Models.Identity +{ + /// + /// EntityType that represents a user belonging to a role + /// + /// + /// + /// + /// This class normally exists inside of the EntityFramework library, not sure why MS chose to explicitly put it there but we don't want + /// references to that so we will create our own here + /// + public class IdentityUserRole + { + /// + /// UserId for the user that is in the role + /// + /// + public virtual TKey UserId { get; set; } + + /// + /// RoleId for the role + /// + /// + public virtual TKey RoleId { get; set; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Mapping/MapperConfiguration.cs b/src/Umbraco.Core/Models/Mapping/MapperConfiguration.cs index 52683231e2..046f4429b2 100644 --- a/src/Umbraco.Core/Models/Mapping/MapperConfiguration.cs +++ b/src/Umbraco.Core/Models/Mapping/MapperConfiguration.cs @@ -12,4 +12,6 @@ namespace Umbraco.Core.Models.Mapping { public abstract void ConfigureMappings(IConfiguration config, ApplicationContext applicationContext); } + + } \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Membership/IUser.cs b/src/Umbraco.Core/Models/Membership/IUser.cs index 47eb074553..f1f9c23971 100644 --- a/src/Umbraco.Core/Models/Membership/IUser.cs +++ b/src/Umbraco.Core/Models/Membership/IUser.cs @@ -38,5 +38,10 @@ namespace Umbraco.Core.Models.Membership /// Exposes the basic profile data /// IProfile ProfileData { get; } + + /// + /// The security stamp used by ASP.Net identity + /// + string SecurityStamp { get; set; } } } \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Membership/User.cs b/src/Umbraco.Core/Models/Membership/User.cs index 3e95a94d3a..7053eaf339 100644 --- a/src/Umbraco.Core/Models/Membership/User.cs +++ b/src/Umbraco.Core/Models/Membership/User.cs @@ -58,6 +58,7 @@ namespace Umbraco.Core.Models.Membership private IUserType _userType; private string _name; + private string _securityStamp; private List _addedSections; private List _removedSections; private ObservableCollection _sectionCollection; @@ -76,6 +77,7 @@ namespace Umbraco.Core.Models.Membership private bool _defaultToLiveEditing; + private static readonly PropertyInfo SecurityStampSelector = ExpressionHelper.GetPropertyInfo(x => x.SecurityStamp); private static readonly PropertyInfo SessionTimeoutSelector = ExpressionHelper.GetPropertyInfo(x => x.SessionTimeout); private static readonly PropertyInfo StartContentIdSelector = ExpressionHelper.GetPropertyInfo(x => x.StartContentId); private static readonly PropertyInfo StartMediaIdSelector = ExpressionHelper.GetPropertyInfo(x => x.StartMediaId); @@ -232,6 +234,22 @@ namespace Umbraco.Core.Models.Membership get { return new UserProfile(this); } } + /// + /// The security stamp used by ASP.Net identity + /// + public string SecurityStamp + { + get { return _securityStamp; } + set + { + SetPropertyValueAndDetectChanges(o => + { + _securityStamp = value; + return _securityStamp; + }, _securityStamp, SecurityStampSelector); + } + } + /// /// Used internally to check if we need to add a section in the repository to the db /// diff --git a/src/Umbraco.Core/Models/Rdbms/ExternalLoginDto.cs b/src/Umbraco.Core/Models/Rdbms/ExternalLoginDto.cs new file mode 100644 index 0000000000..652c9df714 --- /dev/null +++ b/src/Umbraco.Core/Models/Rdbms/ExternalLoginDto.cs @@ -0,0 +1,35 @@ +using System; +using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.DatabaseAnnotations; + +namespace Umbraco.Core.Models.Rdbms +{ + [TableName("umbracoExternalLogin")] + [ExplicitColumns] + [PrimaryKey("Id")] + internal class ExternalLoginDto + { + [Column("id")] + [PrimaryKeyColumn(Name = "PK_umbracoExternalLogin")] + public int Id { get; set; } + + [Column("userId")] + public int UserId { get; set; } + + [Column("loginProvider")] + [Length(4000)] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string LoginProvider { get; set; } + + [Column("providerKey")] + [Length(4000)] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string ProviderKey { get; set; } + + [Column("createDate")] + [Constraint(Default = "getdate()")] + public DateTime CreateDate { get; set; } + + + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Rdbms/UserDto.cs b/src/Umbraco.Core/Models/Rdbms/UserDto.cs index 392010e56d..316a487331 100644 --- a/src/Umbraco.Core/Models/Rdbms/UserDto.cs +++ b/src/Umbraco.Core/Models/Rdbms/UserDto.cs @@ -51,6 +51,11 @@ namespace Umbraco.Core.Models.Rdbms [NullSetting(NullSetting = NullSettings.Null)] [Length(10)] public string UserLanguage { get; set; } + + [Column("securityStampToken")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(255)] + public string SecurityStampToken { get; set; } [ResultColumn] public List User2AppDtos { get; set; } diff --git a/src/Umbraco.Core/Persistence/Factories/ExternalLoginFactory.cs b/src/Umbraco.Core/Persistence/Factories/ExternalLoginFactory.cs new file mode 100644 index 0000000000..4882df3202 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Factories/ExternalLoginFactory.cs @@ -0,0 +1,32 @@ +using Umbraco.Core.Models.Identity; +using Umbraco.Core.Models.Rdbms; + +namespace Umbraco.Core.Persistence.Factories +{ + internal class ExternalLoginFactory + { + public IIdentityUserLogin BuildEntity(ExternalLoginDto dto) + { + var entity = new IdentityUserLogin(dto.Id, dto.LoginProvider, dto.ProviderKey, dto.UserId, dto.CreateDate); + + //on initial construction we don't want to have dirty properties tracked + // http://issues.umbraco.org/issue/U4-1946 + entity.ResetDirtyProperties(false); + return entity; + } + + public ExternalLoginDto BuildDto(IIdentityUserLogin entity) + { + var dto = new ExternalLoginDto + { + Id = entity.Id, + CreateDate = entity.CreateDate, + LoginProvider = entity.LoginProvider, + ProviderKey = entity.ProviderKey, + UserId = entity.UserId + }; + + return dto; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Persistence/Factories/UserFactory.cs b/src/Umbraco.Core/Persistence/Factories/UserFactory.cs index 1f71665f50..1db30302dd 100644 --- a/src/Umbraco.Core/Persistence/Factories/UserFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/UserFactory.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using Umbraco.Core.Models.Membership; @@ -32,7 +33,9 @@ namespace Umbraco.Core.Persistence.Factories IsLockedOut = dto.NoConsole, IsApproved = dto.Disabled == false, Email = dto.Email, - Language = dto.UserLanguage + Language = dto.UserLanguage, + //make it a GUID if it's empty + SecurityStamp = dto.SecurityStampToken.IsNullOrWhiteSpace() ? Guid.NewGuid().ToString() : dto.SecurityStampToken }; foreach (var app in dto.User2AppDtos) @@ -61,7 +64,8 @@ namespace Umbraco.Core.Persistence.Factories UserLanguage = entity.Language, UserName = entity.Name, Type = short.Parse(entity.UserType.Id.ToString(CultureInfo.InvariantCulture)), - User2AppDtos = new List() + User2AppDtos = new List(), + SecurityStampToken = entity.SecurityStamp }; foreach (var app in entity.AllowedSections) diff --git a/src/Umbraco.Core/Persistence/Mappers/UserMapper.cs b/src/Umbraco.Core/Persistence/Mappers/UserMapper.cs index 9c51f53596..0ffd6cbfe1 100644 --- a/src/Umbraco.Core/Persistence/Mappers/UserMapper.cs +++ b/src/Umbraco.Core/Persistence/Mappers/UserMapper.cs @@ -1,11 +1,41 @@ using System; using System.Collections.Concurrent; using System.Linq.Expressions; +using Umbraco.Core.Models.Identity; using Umbraco.Core.Models.Membership; using Umbraco.Core.Models.Rdbms; namespace Umbraco.Core.Persistence.Mappers { + [MapperFor(typeof(IIdentityUserLogin))] + [MapperFor(typeof(IdentityUserLogin))] + public sealed class ExternalLoginMapper : BaseMapper + { + private static readonly ConcurrentDictionary PropertyInfoCacheInstance = new ConcurrentDictionary(); + public ExternalLoginMapper() + { + BuildMap(); + } + + #region Overrides of BaseMapper + + internal override ConcurrentDictionary PropertyInfoCache + { + get { return PropertyInfoCacheInstance; } + } + + internal override void BuildMap() + { + CacheMap(src => src.Id, dto => dto.Id); + CacheMap(src => src.CreateDate, dto => dto.CreateDate); + CacheMap(src => src.LoginProvider, dto => dto.LoginProvider); + CacheMap(src => src.ProviderKey, dto => dto.ProviderKey); + CacheMap(src => src.UserId, dto => dto.UserId); + } + + #endregion + } + [MapperFor(typeof(IUser))] [MapperFor(typeof(User))] public sealed class UserMapper : BaseMapper diff --git a/src/Umbraco.Core/Persistence/Migrations/Initial/DatabaseSchemaCreation.cs b/src/Umbraco.Core/Persistence/Migrations/Initial/DatabaseSchemaCreation.cs index b77e0843ea..25d5f2a9af 100644 --- a/src/Umbraco.Core/Persistence/Migrations/Initial/DatabaseSchemaCreation.cs +++ b/src/Umbraco.Core/Persistence/Migrations/Initial/DatabaseSchemaCreation.cs @@ -81,7 +81,9 @@ namespace Umbraco.Core.Persistence.Migrations.Initial {40, typeof (ServerRegistrationDto)}, {41, typeof (AccessDto)}, - {42, typeof (AccessRuleDto)} + {42, typeof (AccessRuleDto)}, + + {43, typeof (ExternalLoginDto)} }; #endregion diff --git a/src/Umbraco.Core/Persistence/Migrations/Initial/DatabaseSchemaResult.cs b/src/Umbraco.Core/Persistence/Migrations/Initial/DatabaseSchemaResult.cs index ca0cf7ebd2..c2acbd3c97 100644 --- a/src/Umbraco.Core/Persistence/Migrations/Initial/DatabaseSchemaResult.cs +++ b/src/Umbraco.Core/Persistence/Migrations/Initial/DatabaseSchemaResult.cs @@ -98,6 +98,12 @@ namespace Umbraco.Core.Persistence.Migrations.Initial return new Version(7, 0, 0); } + //if the error is for umbracoAccess it must be the previous version to 7.3 since that is when it is added + if (Errors.Any(x => x.Item1.Equals("Table") && (x.Item2.InvariantEquals("umbracoAccess")))) + { + return new Version(7, 2, 5); + } + return UmbracoVersion.Current; } diff --git a/src/Umbraco.Core/Persistence/Migrations/Upgrades/TargetVersionSevenThreeZero/AddExternalLoginsTable.cs b/src/Umbraco.Core/Persistence/Migrations/Upgrades/TargetVersionSevenThreeZero/AddExternalLoginsTable.cs new file mode 100644 index 0000000000..b29aff9048 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Migrations/Upgrades/TargetVersionSevenThreeZero/AddExternalLoginsTable.cs @@ -0,0 +1,28 @@ +using System.Linq; +using Umbraco.Core.Configuration; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; + +namespace Umbraco.Core.Persistence.Migrations.Upgrades.TargetVersionSevenThreeZero +{ + [Migration("7.3.0", 9, GlobalSettings.UmbracoMigrationName)] + public class AddExternalLoginsTable : MigrationBase + { + public override void Up() + { + //Don't exeucte if the table is already there + var tables = SqlSyntax.GetTablesInSchema(Context.Database).ToArray(); + if (tables.InvariantContains("umbracoExternalLogin")) return; + + Create.Table("umbracoExternalLogin") + .WithColumn("id").AsInt32().Identity().PrimaryKey("PK_umbracoExternalLogin") + .WithColumn("userId").AsInt32().NotNullable().ForeignKey("FK_umbracoExternalLogin_umbracoUser_id", "umbracoUser", "id") + .WithColumn("loginProvider").AsString(4000).NotNullable() + .WithColumn("providerKey").AsString(4000).NotNullable() + .WithColumn("createDate").AsDateTime().NotNullable().WithDefault(SystemMethods.CurrentDateTime); + } + + public override void Down() + { + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Persistence/Migrations/Upgrades/TargetVersionSevenThreeZero/AddUserSecurityStampColumn.cs b/src/Umbraco.Core/Persistence/Migrations/Upgrades/TargetVersionSevenThreeZero/AddUserSecurityStampColumn.cs new file mode 100644 index 0000000000..21ead0996b --- /dev/null +++ b/src/Umbraco.Core/Persistence/Migrations/Upgrades/TargetVersionSevenThreeZero/AddUserSecurityStampColumn.cs @@ -0,0 +1,22 @@ +using System.Linq; +using Umbraco.Core.Configuration; + +namespace Umbraco.Core.Persistence.Migrations.Upgrades.TargetVersionSevenThreeZero +{ + [Migration("7.3.0", 10, GlobalSettings.UmbracoMigrationName)] + public class AddUserSecurityStampColumn : MigrationBase + { + public override void Up() + { + //Don't exeucte if the column is already there + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToArray(); + if (columns.Any(x => x.TableName.InvariantEquals("umbracoUser") && x.ColumnName.InvariantEquals("securityStampToken"))) return; + + Create.Column("securityStampToken").OnTable("umbracoUser").AsString(255).Nullable(); + } + + public override void Down() + { + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Persistence/Repositories/ExternalLoginRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ExternalLoginRepository.cs new file mode 100644 index 0000000000..97c6e4fcf5 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/ExternalLoginRepository.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.Identity; +using Umbraco.Core.Logging; +using Umbraco.Core.Models.EntityBase; +using Umbraco.Core.Models.Identity; +using Umbraco.Core.Models.Rdbms; +using Umbraco.Core.Persistence.Factories; +using Umbraco.Core.Persistence.Querying; +using Umbraco.Core.Persistence.SqlSyntax; +using Umbraco.Core.Persistence.UnitOfWork; + +namespace Umbraco.Core.Persistence.Repositories +{ + internal class ExternalLoginRepository : PetaPocoRepositoryBase, IExternalLoginRepository + { + public ExternalLoginRepository(IDatabaseUnitOfWork work, CacheHelper cache, ILogger logger, ISqlSyntaxProvider sqlSyntax) + : base(work, cache, logger, sqlSyntax) + { + } + + public void DeleteUserLogins(int memberId) + { + using (var t = Database.GetTransaction()) + { + Database.Execute("DELETE FROM ExternalLogins WHERE UserId=@userId", new { userId = memberId }); + + t.Complete(); + } + } + + public void SaveUserLogins(int memberId, IEnumerable logins) + { + using (var t = Database.GetTransaction()) + { + //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 + }); + } + + t.Complete(); + } + } + + protected override IIdentityUserLogin PerformGet(int id) + { + var sql = GetBaseQuery(false); + sql.Where(GetBaseWhereClause(), new { Id = id }); + + var macroDto = Database.Fetch(sql).FirstOrDefault(); + if (macroDto == null) + return null; + + var factory = new ExternalLoginFactory(); + var entity = factory.BuildEntity(macroDto); + + //on initial construction we don't want to have dirty properties tracked + // http://issues.umbraco.org/issue/U4-1946 + ((TracksChangesEntityBase)entity).ResetDirtyProperties(false); + + return entity; + } + + protected override IEnumerable PerformGetAll(params int[] ids) + { + if (ids.Any()) + { + return PerformGetAllOnIds(ids); + } + + var sql = GetBaseQuery(false); + + return ConvertFromDtos(Database.Fetch(sql)) + .ToArray();// we don't want to re-iterate again! + } + + private IEnumerable PerformGetAllOnIds(params int[] ids) + { + if (ids.Any() == false) yield break; + foreach (var id in ids) + { + yield return Get(id); + } + } + + private IEnumerable ConvertFromDtos(IEnumerable dtos) + { + var factory = new ExternalLoginFactory(); + foreach (var entity in dtos.Select(factory.BuildEntity)) + { + //on initial construction we don't want to have dirty properties tracked + // http://issues.umbraco.org/issue/U4-1946 + ((TracksChangesEntityBase)entity).ResetDirtyProperties(false); + + yield return entity; + } + } + + protected override IEnumerable PerformGetByQuery(IQuery query) + { + var sqlClause = GetBaseQuery(false); + var translator = new SqlTranslator(sqlClause, query); + var sql = translator.Translate(); + + var dtos = Database.Fetch(sql); + + foreach (var dto in dtos) + { + yield return Get(dto.Id); + } + } + + protected override Sql GetBaseQuery(bool isCount) + { + var sql = new Sql(); + if (isCount) + { + sql.Select("COUNT(*)").From(SqlSyntax); + } + else + { + sql.Select("*").From(SqlSyntax); + } + return sql; + } + + protected override string GetBaseWhereClause() + { + return "umbracoExternalLogin.id = @Id"; + } + + protected override IEnumerable GetDeleteClauses() + { + var list = new List + { + "DELETE FROM umbracoExternalLogin WHERE id = @Id" + }; + return list; + } + + protected override Guid NodeObjectTypeId + { + get { throw new NotImplementedException(); } + } + + protected override void PersistNewItem(IIdentityUserLogin entity) + { + ((Entity)entity).AddingEntity(); + + var factory = new ExternalLoginFactory(); + var dto = factory.BuildDto(entity); + + var id = Convert.ToInt32(Database.Insert(dto)); + entity.Id = id; + + entity.ResetDirtyProperties(); + } + + protected override void PersistUpdatedItem(IIdentityUserLogin entity) + { + ((Entity)entity).UpdatingEntity(); + + var factory = new ExternalLoginFactory(); + var dto = factory.BuildDto(entity); + + Database.Update(dto); + + entity.ResetDirtyProperties(); + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Persistence/Repositories/Interfaces/IExternalLoginRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Interfaces/IExternalLoginRepository.cs new file mode 100644 index 0000000000..17fc153980 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/Interfaces/IExternalLoginRepository.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Microsoft.AspNet.Identity; +using Umbraco.Core.Models.Identity; + +namespace Umbraco.Core.Persistence.Repositories +{ + public interface IExternalLoginRepository : IRepositoryQueryable + { + void SaveUserLogins(int memberId, IEnumerable logins); + void DeleteUserLogins(int memberId); + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Persistence/Repositories/UserRepository.cs b/src/Umbraco.Core/Persistence/Repositories/UserRepository.cs index fb66dc7ade..b26daaee7f 100644 --- a/src/Umbraco.Core/Persistence/Repositories/UserRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/UserRepository.cs @@ -122,7 +122,8 @@ namespace Umbraco.Core.Persistence.Repositories "DELETE FROM umbracoUser2NodeNotify WHERE userId = @Id", "DELETE FROM umbracoUserLogins WHERE userID = @Id", "DELETE FROM umbracoUser2app WHERE " + SqlSyntax.GetQuotedColumnName("user") + "=@Id", - "DELETE FROM umbracoUser WHERE id = @Id" + "DELETE FROM umbracoUser WHERE id = @Id", + "DELETE FROM umbracoExternalLogin WHERE id = @Id" }; return list; } @@ -169,7 +170,8 @@ namespace Umbraco.Core.Persistence.Repositories {"userName", "Name"}, {"userLogin", "Username"}, {"userEmail", "Email"}, - {"userLanguage", "Language"} + {"userLanguage", "Language"}, + {"securityStampToken", "SecurityStamp"} }; //create list of properties that have changed @@ -182,6 +184,15 @@ namespace Umbraco.Core.Persistence.Repositories if (dirtyEntity.IsPropertyDirty("RawPasswordValue") && entity.RawPasswordValue.IsNullOrWhiteSpace() == false) { changedCols.Add("userPassword"); + + //special case - when using ASP.Net identity the user manager will take care of updating the security stamp, however + // when not using ASP.Net identity (i.e. old membership providers), we'll need to take care of updating this manually + // so we can just detect if that property is dirty, if it's not we'll set it manually + if (dirtyEntity.IsPropertyDirty("SecurityStamp") == false) + { + userDto.SecurityStampToken = entity.SecurityStamp = Guid.NewGuid().ToString(); + changedCols.Add("securityStampToken"); + } } //only update the changed cols diff --git a/src/Umbraco.Core/Persistence/RepositoryFactory.cs b/src/Umbraco.Core/Persistence/RepositoryFactory.cs index be6d35ca27..356e876c54 100644 --- a/src/Umbraco.Core/Persistence/RepositoryFactory.cs +++ b/src/Umbraco.Core/Persistence/RepositoryFactory.cs @@ -64,6 +64,13 @@ namespace Umbraco.Core.Persistence #endregion + public virtual IExternalLoginRepository CreateExternalLoginRepository(IDatabaseUnitOfWork uow) + { + return new ExternalLoginRepository(uow, + _cacheHelper, + _logger, _sqlSyntax); + } + public virtual IPublicAccessRepository CreatePublicAccessRepository(IDatabaseUnitOfWork uow) { return new PublicAccessRepository(uow, diff --git a/src/Umbraco.Core/Security/AuthenticationExtensions.cs b/src/Umbraco.Core/Security/AuthenticationExtensions.cs index 8511d39125..9addb2e782 100644 --- a/src/Umbraco.Core/Security/AuthenticationExtensions.cs +++ b/src/Umbraco.Core/Security/AuthenticationExtensions.cs @@ -1,12 +1,19 @@ using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Security.Principal; using System.Threading; using System.Web; using System.Web.Security; +using AutoMapper; +using Microsoft.Owin; using Newtonsoft.Json; using Umbraco.Core.Configuration; +using Umbraco.Core.Models.Membership; namespace Umbraco.Core.Security { @@ -158,8 +165,60 @@ namespace Umbraco.Core.Security Expires = DateTime.Now.AddYears(-1), Path = "/" }; + //remove the external login cookie too + var extLoginCookie = new CookieHeaderValue(Constants.Security.BackOfficeExternalCookieName, "") + { + Expires = DateTime.Now.AddYears(-1), + Path = "/" + }; - response.Headers.AddCookies(new[] { authCookie, prevCookie }); + response.Headers.AddCookies(new[] { authCookie, prevCookie, extLoginCookie }); + } + + /// + /// This adds the forms authentication cookie for webapi since cookies are handled differently + /// + /// + /// + public static FormsAuthenticationTicket UmbracoLoginWebApi(this HttpResponseMessage response, IUser user) + { + if (response == null) throw new ArgumentNullException("response"); + + //remove the external login cookie + var extLoginCookie = new CookieHeaderValue(Constants.Security.BackOfficeExternalCookieName, "") + { + Expires = DateTime.Now.AddYears(-1), + Path = "/" + }; + + var userDataString = JsonConvert.SerializeObject(Mapper.Map(user)); + + var ticket = new FormsAuthenticationTicket( + 4, + user.Username, + DateTime.Now, + DateTime.Now.AddMinutes(GlobalSettings.TimeOutInMinutes), + true, + userDataString, + "/" + ); + + // Encrypt the cookie using the machine key for secure transport + var encrypted = FormsAuthentication.Encrypt(ticket); + + //add the cookie + var authCookie = new CookieHeaderValue(UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName, encrypted) + { + //Umbraco has always persisted it's original cookie for 1 day so we'll keep it that way + Expires = DateTime.Now.AddMinutes(1440), + Path = "/", + Secure = GlobalSettings.UseSSL, + HttpOnly = true + }; + + response.Headers.AddCookies(new[] { authCookie, extLoginCookie }); + + return ticket; } /// @@ -211,7 +270,6 @@ namespace Umbraco.Core.Security GlobalSettings.TimeOutInMinutes, //Umbraco has always persisted it's original cookie for 1 day so we'll keep it that way 1440, - "/", UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName, UmbracoConfig.For.UmbracoSettings().Security.AuthCookieDomain); } @@ -268,6 +326,23 @@ namespace Umbraco.Core.Security return new HttpContextWrapper(http).GetUmbracoAuthTicket(); } + internal static FormsAuthenticationTicket GetUmbracoAuthTicket(this IOwinContext ctx) + { + if (ctx == null) throw new ArgumentNullException("ctx"); + //get the ticket + try + { + return GetAuthTicket(ctx.Request.Cookies.ToDictionary(x => x.Key, x => x.Value), UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName); + } + catch (Exception) + { + //TODO: Do we need to do more here?? need to make sure that the forms cookie is gone, but is that + // taken care of in our custom middleware somehow? + ctx.Authentication.SignOut(); + return null; + } + } + /// /// This clears the forms authentication cookie /// @@ -276,8 +351,8 @@ namespace Umbraco.Core.Security private static void Logout(this HttpContextBase http, string cookieName) { if (http == null) throw new ArgumentNullException("http"); - //clear the preview cookie too - var cookies = new[] { cookieName, Constants.Web.PreviewCookieName }; + //clear the preview cookie and external login + var cookies = new[] { cookieName, Constants.Web.PreviewCookieName, Constants.Security.BackOfficeExternalCookieName }; foreach (var c in cookies) { //remove from the request @@ -301,16 +376,18 @@ namespace Umbraco.Core.Security private static FormsAuthenticationTicket GetAuthTicket(this HttpContextBase http, string cookieName) { - if (http == null) throw new ArgumentNullException("http"); - var formsCookie = http.Request.Cookies[cookieName]; - if (formsCookie == null) + var asDictionary = new Dictionary(); + for (var i = 0; i < http.Request.Cookies.Keys.Count; i++) { - return null; + var key = http.Request.Cookies.Keys.Get(i); + asDictionary[key] = http.Request.Cookies[key].Value; } + //get the ticket try { - return FormsAuthentication.Decrypt(formsCookie.Value); + + return GetAuthTicket(asDictionary, cookieName); } catch (Exception) { @@ -320,6 +397,21 @@ namespace Umbraco.Core.Security } } + private static FormsAuthenticationTicket GetAuthTicket(IDictionary cookies, string cookieName) + { + if (cookies == null) throw new ArgumentNullException("cookies"); + + if (cookies.ContainsKey(cookieName) == false) return null; + + var formsCookie = cookies[cookieName]; + if (formsCookie == null) + { + return null; + } + //get the ticket + return FormsAuthentication.Decrypt(formsCookie); + } + /// /// Renews the forms authentication ticket & cookie /// @@ -373,7 +465,6 @@ namespace Umbraco.Core.Security /// The user data. /// The login timeout mins. /// The minutes persisted. - /// The cookie path. /// Name of the cookie. /// The cookie domain. private static FormsAuthenticationTicket CreateAuthTicketAndCookie(this HttpContextBase http, @@ -381,7 +472,6 @@ namespace Umbraco.Core.Security string userData, int loginTimeoutMins, int minutesPersisted, - string cookiePath, string cookieName, string cookieDomain) { @@ -394,7 +484,7 @@ namespace Umbraco.Core.Security DateTime.Now.AddMinutes(loginTimeoutMins), true, userData, - cookiePath + "/" ); // Encrypt the cookie using the machine key for secure transport @@ -404,7 +494,8 @@ namespace Umbraco.Core.Security hash) { Expires = DateTime.Now.AddMinutes(minutesPersisted), - Domain = cookieDomain + Domain = cookieDomain, + Path = "/" }; if (GlobalSettings.UseSSL) diff --git a/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs new file mode 100644 index 0000000000..b6d19b78eb --- /dev/null +++ b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs @@ -0,0 +1,35 @@ +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNet.Identity; +using Umbraco.Core.Models.Identity; + +namespace Umbraco.Core.Security +{ + public class BackOfficeClaimsIdentityFactory : ClaimsIdentityFactory + { + /// + /// Create a ClaimsIdentity from a user + /// + /// + /// + public override async Task CreateAsync(UserManager manager, BackOfficeIdentityUser user, string authenticationType) + { + var baseIdentity = await base.CreateAsync(manager, user, authenticationType); + + var umbracoIdentity = new UmbracoBackOfficeIdentity(baseIdentity, new UserData() + { + Id = user.Id, + Username = user.UserName, + RealName = user.Name, + AllowedApplications = user.AllowedSections, + Culture = user.Culture, + Roles = user.Roles.Select(x => x.RoleId).ToArray(), + StartContentNode = user.StartContentId, + StartMediaNode = user.StartMediaId + }); + + return umbracoIdentity; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Security/BackOfficeUserManager.cs b/src/Umbraco.Core/Security/BackOfficeUserManager.cs new file mode 100644 index 0000000000..def46b7556 --- /dev/null +++ b/src/Umbraco.Core/Security/BackOfficeUserManager.cs @@ -0,0 +1,172 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using System.Web.Security; +using Microsoft.AspNet.Identity; +using Microsoft.AspNet.Identity.Owin; +using Microsoft.Owin; +using Umbraco.Core.Models.Identity; +using Umbraco.Core.Services; + +namespace Umbraco.Core.Security +{ + /// + /// Default back office user manager + /// + public class BackOfficeUserManager : BackOfficeUserManager + { + public BackOfficeUserManager(IUserStore store) + : base(store) + { + } + + /// + /// Creates a BackOfficeUserManager instance with all default options and the default BackOfficeUserManager + /// + /// + /// + /// + /// + /// + public static BackOfficeUserManager Create( + IdentityFactoryOptions options, + IUserService userService, + IExternalLoginService externalLoginService, + MembershipProviderBase membershipProvider) + { + if (options == null) throw new ArgumentNullException("options"); + if (userService == null) throw new ArgumentNullException("userService"); + if (externalLoginService == null) throw new ArgumentNullException("externalLoginService"); + + var manager = new BackOfficeUserManager(new BackOfficeUserStore(userService, externalLoginService, membershipProvider)); + + return InitUserManager(manager, membershipProvider, options); + } + + /// + /// Creates a BackOfficeUserManager instance with all default options and a custom BackOfficeUserManager instance + /// + /// + /// + /// + /// + public static BackOfficeUserManager Create( + IdentityFactoryOptions options, + BackOfficeUserStore customUserStore, + MembershipProviderBase membershipProvider) + { + if (options == null) throw new ArgumentNullException("options"); + if (customUserStore == null) throw new ArgumentNullException("customUserStore"); + + var manager = new BackOfficeUserManager(customUserStore); + + return InitUserManager(manager, membershipProvider, options); + } + + /// + /// Initializes the user manager with the correct options + /// + /// + /// + /// + /// + private static BackOfficeUserManager InitUserManager(BackOfficeUserManager manager, MembershipProviderBase membershipProvider, IdentityFactoryOptions options) + { + // Configure validation logic for usernames + manager.UserValidator = new UserValidator(manager) + { + AllowOnlyAlphanumericUserNames = false, + RequireUniqueEmail = true + }; + + // Configure validation logic for passwords + manager.PasswordValidator = new PasswordValidator + { + RequiredLength = membershipProvider.MinRequiredPasswordLength, + RequireNonLetterOrDigit = membershipProvider.MinRequiredNonAlphanumericCharacters > 0, + RequireDigit = false, + RequireLowercase = false, + RequireUppercase = false + }; + + //use a custom hasher based on our membership provider + manager.PasswordHasher = new MembershipPasswordHasher(membershipProvider); + + var dataProtectionProvider = options.DataProtectionProvider; + if (dataProtectionProvider != null) + { + manager.UserTokenProvider = new DataProtectorTokenProvider(dataProtectionProvider.Create("ASP.NET Identity")); + } + + //custom identity factory for creating the identity object for which we auth against in the back office + manager.ClaimsIdentityFactory = new BackOfficeClaimsIdentityFactory(); + + //NOTE: Not implementing these, if people need custom 2 factor auth, they'll need to implement their own UserStore to suport it + + //// Register two factor authentication providers. This application uses Phone and Emails as a step of receiving a code for verifying the user + //// You can write your own provider and plug in here. + //manager.RegisterTwoFactorProvider("PhoneCode", new PhoneNumberTokenProvider + //{ + // MessageFormat = "Your security code is: {0}" + //}); + //manager.RegisterTwoFactorProvider("EmailCode", new EmailTokenProvider + //{ + // Subject = "Security Code", + // BodyFormat = "Your security code is: {0}" + //}); + + //manager.EmailService = new EmailService(); + //manager.SmsService = new SmsService(); + + return manager; + } + } + + /// + /// Generic Back office user manager + /// + public class BackOfficeUserManager : UserManager + where T : BackOfficeIdentityUser + { + public BackOfficeUserManager(IUserStore store) + : base(store) + { + } + + #region What we support do not currently + + //NOTE: Not sure if we really want/need to ever support this + public override bool SupportsUserClaim + { + get { return false; } + } + + //TODO: Support this + public override bool SupportsQueryableUsers + { + get { return false; } + } + + //TODO: Support this + public override bool SupportsUserLockout + { + get { return false; } + } + + //TODO: Support this + public override bool SupportsUserTwoFactor + { + get { return false; } + } + + //TODO: Support this + public override bool SupportsUserPhoneNumber + { + get { return false; } + } + #endregion + + } +} diff --git a/src/Umbraco.Core/Security/BackOfficeUserStore.cs b/src/Umbraco.Core/Security/BackOfficeUserStore.cs new file mode 100644 index 0000000000..f6d8222c44 --- /dev/null +++ b/src/Umbraco.Core/Security/BackOfficeUserStore.cs @@ -0,0 +1,588 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Web.Security; +using AutoMapper; +using Microsoft.AspNet.Identity; +using Umbraco.Core.Models.Identity; +using Umbraco.Core.Models.Membership; +using Umbraco.Core.Services; + +namespace Umbraco.Core.Security +{ + public class BackOfficeUserStore : DisposableObject, + IUserStore, + IUserPasswordStore, + IUserEmailStore, + IUserLoginStore, + IUserRoleStore, + IUserSecurityStampStore + + //TODO: This would require additional columns/tables for now people will need to implement this on their own + //IUserPhoneNumberStore, + //IUserTwoFactorStore, + + //TODO: This will require additional columns/tables + //IUserLockoutStore + + //TODO: To do this we need to implement IQueryable - we'll have an IQuerable implementation soon with the UmbracoLinqPadDriver implementation + //IQueryableUserStore + { + private readonly IUserService _userService; + private readonly IExternalLoginService _externalLoginService; + private bool _disposed = false; + + public BackOfficeUserStore(IUserService userService, IExternalLoginService externalLoginService, MembershipProviderBase usersMembershipProvider) + { + _userService = userService; + _externalLoginService = externalLoginService; + if (userService == null) throw new ArgumentNullException("userService"); + if (usersMembershipProvider == null) throw new ArgumentNullException("usersMembershipProvider"); + if (externalLoginService == null) throw new ArgumentNullException("externalLoginService"); + + _userService = userService; + _externalLoginService = externalLoginService; + + if (usersMembershipProvider.PasswordFormat != MembershipPasswordFormat.Hashed) + { + throw new InvalidOperationException("Cannot use ASP.Net Identity with UmbracoMembersUserStore when the password format is not Hashed"); + } + } + + /// + /// Handles the disposal of resources. Derived from abstract class which handles common required locking logic. + /// + protected override void DisposeResources() + { + _disposed = true; + } + + /// + /// Insert a new user + /// + /// + /// + public Task CreateAsync(BackOfficeIdentityUser user) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + + var userType = _userService.GetUserTypeByAlias( + user.UserTypeAlias.IsNullOrWhiteSpace() ? _userService.GetDefaultMemberType() : user.UserTypeAlias); + + var member = new User(userType) + { + DefaultToLiveEditing = false, + Email = user.Email, + Language = Configuration.GlobalSettings.DefaultUILanguage, + Name = user.Name, + Username = user.UserName, + StartContentId = -1, + StartMediaId = -1, + IsLockedOut = false, + IsApproved = true + }; + + UpdateMemberProperties(member, user); + + //the password must be 'something' it could be empty if authenticating + // with an external provider so we'll just generate one and prefix it, the + // prefix will help us determine if the password hasn't actually been specified yet. + if (member.RawPasswordValue.IsNullOrWhiteSpace()) + { + //this will hash the guid with a salt so should be nicely random + var aspHasher = new PasswordHasher(); + member.RawPasswordValue = "___UIDEMPTYPWORD__" + + aspHasher.HashPassword(Guid.NewGuid().ToString("N")); + + } + _userService.Save(member); + + //re-assign id + user.Id = member.Id; + + return Task.FromResult(0); + } + + /// + /// Update a user + /// + /// + /// + public async Task UpdateAsync(BackOfficeIdentityUser user) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + + var asInt = user.Id.TryConvertTo(); + if (asInt == false) + { + throw new InvalidOperationException("The user id must be an integer to work with the Umbraco"); + } + + var found = _userService.GetUserById(asInt.Result); + if (found != null) + { + if (UpdateMemberProperties(found, user)) + { + _userService.Save(found); + } + + if (user.LoginsChanged) + { + var logins = await GetLoginsAsync(user); + _externalLoginService.SaveUserLogins(found.Id, logins); + } + } + } + + /// + /// Delete a user + /// + /// + /// + public Task DeleteAsync(BackOfficeIdentityUser user) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + + var asInt = user.Id.TryConvertTo(); + if (asInt == false) + { + throw new InvalidOperationException("The user id must be an integer to work with the Umbraco"); + } + + var found = _userService.GetUserById(asInt.Result); + if (found != null) + { + _userService.Delete(found); + } + _externalLoginService.DeleteUserLogins(asInt.Result); + + return Task.FromResult(0); + } + + /// + /// Finds a user + /// + /// + /// + public Task FindByIdAsync(int userId) + { + ThrowIfDisposed(); + var user = _userService.GetUserById(userId); + if (user == null) + { + return null; + } + return Task.FromResult(AssignLoginsCallback(Mapper.Map(user))); + } + + /// + /// Find a user by name + /// + /// + /// + public Task FindByNameAsync(string userName) + { + ThrowIfDisposed(); + var user = _userService.GetByUsername(userName); + if (user == null) + { + return null; + } + + var result = AssignLoginsCallback(Mapper.Map(user)); + + return Task.FromResult(result); + } + + /// + /// Set the user password hash + /// + /// + /// + public Task SetPasswordHashAsync(BackOfficeIdentityUser user, string passwordHash) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + if (passwordHash.IsNullOrWhiteSpace()) throw new ArgumentNullException("passwordHash"); + + user.PasswordHash = passwordHash; + + return Task.FromResult(0); + } + + /// + /// Get the user password hash + /// + /// + /// + public Task GetPasswordHashAsync(BackOfficeIdentityUser user) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + + return Task.FromResult(user.PasswordHash); + } + + /// + /// Returns true if a user has a password set + /// + /// + /// + public Task HasPasswordAsync(BackOfficeIdentityUser user) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + + return Task.FromResult(user.PasswordHash.IsNullOrWhiteSpace() == false); + } + + /// + /// Set the user email + /// + /// + /// + public Task SetEmailAsync(BackOfficeIdentityUser user, string email) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + if (email.IsNullOrWhiteSpace()) throw new ArgumentNullException("email"); + + user.Email = email; + + return Task.FromResult(0); + } + + /// + /// Get the user email + /// + /// + /// + public Task GetEmailAsync(BackOfficeIdentityUser user) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + + return Task.FromResult(user.Email); + } + + /// + /// Returns true if the user email is confirmed + /// + /// + /// + public Task GetEmailConfirmedAsync(BackOfficeIdentityUser user) + { + ThrowIfDisposed(); + throw new NotImplementedException(); + } + + /// + /// Sets whether the user email is confirmed + /// + /// + /// + public Task SetEmailConfirmedAsync(BackOfficeIdentityUser user, bool confirmed) + { + ThrowIfDisposed(); + throw new NotImplementedException(); + } + + /// + /// Returns the user associated with this email + /// + /// + /// + public Task FindByEmailAsync(string email) + { + ThrowIfDisposed(); + var user = _userService.GetByEmail(email); + var result = user == null + ? null + : Mapper.Map(user); + + return Task.FromResult(AssignLoginsCallback(result)); + } + + /// + /// Adds a user login with the specified provider and key + /// + /// + /// + public Task AddLoginAsync(BackOfficeIdentityUser user, UserLoginInfo login) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + if (login == null) throw new ArgumentNullException("login"); + + var logins = user.Logins; + var instance = new IdentityUserLogin(login.LoginProvider, login.ProviderKey, user.Id); + var userLogin = instance; + logins.Add(userLogin); + + return Task.FromResult(0); + } + + /// + /// Removes the user login with the specified combination if it exists + /// + /// + /// + public Task RemoveLoginAsync(BackOfficeIdentityUser user, UserLoginInfo login) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + if (login == null) throw new ArgumentNullException("login"); + + var provider = login.LoginProvider; + var key = login.ProviderKey; + var userLogin = user.Logins.SingleOrDefault((l => l.LoginProvider == provider && l.ProviderKey == key)); + if (userLogin != null) + user.Logins.Remove(userLogin); + + return Task.FromResult(0); + } + + /// + /// Returns the linked accounts for this user + /// + /// + /// + public Task> GetLoginsAsync(BackOfficeIdentityUser user) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + return Task.FromResult((IList) + user.Logins.Select(l => new UserLoginInfo(l.LoginProvider, l.ProviderKey)).ToList()); + } + + /// + /// Returns the user associated with this login + /// + /// + public Task FindAsync(UserLoginInfo login) + { + ThrowIfDisposed(); + if (login == null) throw new ArgumentNullException("login"); + + //get all logins associated with the login id + var result = _externalLoginService.Find(login).ToArray(); + if (result.Any()) + { + //return the first member that matches the result + var output = (from l in result + select _userService.GetUserById(l.UserId) + into user + where user != null + select Mapper.Map(user)).FirstOrDefault(); + + return Task.FromResult(AssignLoginsCallback(output)); + } + + return Task.FromResult(null); + } + + + /// + /// Adds a user to a role (section) + /// + /// + /// + public Task AddToRoleAsync(BackOfficeIdentityUser user, string roleName) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + + if (user.AllowedSections.InvariantContains(roleName)) return Task.FromResult(0); + + var asInt = user.Id.TryConvertTo(); + if (asInt == false) + { + throw new InvalidOperationException("The user id must be an integer to work with the Umbraco"); + } + + var found = _userService.GetUserById(asInt.Result); + + if (found != null) + { + found.AddAllowedSection(roleName); + } + + return Task.FromResult(0); + } + + /// + /// Removes the role (allowed section) for the user + /// + /// + /// + public Task RemoveFromRoleAsync(BackOfficeIdentityUser user, string roleName) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + + if (user.AllowedSections.InvariantContains(roleName) == false) return Task.FromResult(0); + + var asInt = user.Id.TryConvertTo(); + if (asInt == false) + { + throw new InvalidOperationException("The user id must be an integer to work with the Umbraco"); + } + + var found = _userService.GetUserById(asInt.Result); + + if (found != null) + { + found.RemoveAllowedSection(roleName); + } + + return Task.FromResult(0); + } + + /// + /// Returns the roles for this user + /// + /// + /// + public Task> GetRolesAsync(BackOfficeIdentityUser user) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + return Task.FromResult((IList)user.AllowedSections.ToList()); + } + + /// + /// Returns true if a user is in the role + /// + /// + /// + public Task IsInRoleAsync(BackOfficeIdentityUser user, string roleName) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + return Task.FromResult(user.AllowedSections.InvariantContains(roleName)); + } + + /// + /// Set the security stamp for the user + /// + /// + /// + public Task SetSecurityStampAsync(BackOfficeIdentityUser user, string stamp) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + + user.SecurityStamp = stamp; + return Task.FromResult(0); + } + + /// + /// Get the user security stamp + /// + /// + /// + public Task GetSecurityStampAsync(BackOfficeIdentityUser user) + { + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException("user"); + + //the stamp cannot be null, so if it is currently null then we'll just return a hash of the password + return Task.FromResult(user.SecurityStamp.IsNullOrWhiteSpace() + ? user.PasswordHash.ToMd5() + : user.SecurityStamp); + } + + private BackOfficeIdentityUser AssignLoginsCallback(BackOfficeIdentityUser user) + { + if (user != null) + { + user.SetLoginsCallback(new Lazy>(() => + _externalLoginService.GetAll(user.Id))); + } + return user; + } + + private bool UpdateMemberProperties(Models.Membership.IUser user, BackOfficeIdentityUser identityUser) + { + var anythingChanged = false; + //don't assign anything if nothing has changed as this will trigger + //the track changes of the model + if (user.Name != identityUser.Name && identityUser.Name.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + user.Name = identityUser.Name; + } + if (user.Email != identityUser.Email && identityUser.Email.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + user.Email = identityUser.Email; + } + if (user.FailedPasswordAttempts != identityUser.AccessFailedCount) + { + anythingChanged = true; + user.FailedPasswordAttempts = identityUser.AccessFailedCount; + } + if (user.IsLockedOut != identityUser.LockoutEnabled) + { + anythingChanged = true; + user.IsLockedOut = identityUser.LockoutEnabled; + } + if (user.Username != identityUser.UserName && identityUser.UserName.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + user.Username = identityUser.UserName; + } + if (user.RawPasswordValue != identityUser.PasswordHash && identityUser.PasswordHash.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + user.RawPasswordValue = identityUser.PasswordHash; + } + + if (user.Language != identityUser.Culture && identityUser.Culture.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + user.Language = identityUser.Culture; + } + if (user.StartMediaId != identityUser.StartMediaId) + { + anythingChanged = true; + user.StartMediaId = identityUser.StartMediaId; + } + if (user.StartContentId != identityUser.StartContentId) + { + anythingChanged = true; + user.StartContentId = identityUser.StartContentId; + } + if (user.SecurityStamp != identityUser.SecurityStamp) + { + anythingChanged = true; + user.SecurityStamp = identityUser.SecurityStamp; + } + if (user.AllowedSections.ContainsAll(identityUser.AllowedSections) == false + || identityUser.AllowedSections.ContainsAll(user.AllowedSections) == false) + { + anythingChanged = true; + foreach (var allowedSection in user.AllowedSections) + { + user.RemoveAllowedSection(allowedSection); + } + foreach (var allowedApplication in identityUser.AllowedSections) + { + user.AddAllowedSection(allowedApplication); + } + } + + return anythingChanged; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(GetType().Name); + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Security/UmbracoMembersMembershipProviderBase.cs b/src/Umbraco.Core/Security/IUmbracoMemberTypeMembershipProvider.cs similarity index 100% rename from src/Umbraco.Core/Security/UmbracoMembersMembershipProviderBase.cs rename to src/Umbraco.Core/Security/IUmbracoMemberTypeMembershipProvider.cs diff --git a/src/Umbraco.Core/Security/MembershipPasswordHasher.cs b/src/Umbraco.Core/Security/MembershipPasswordHasher.cs new file mode 100644 index 0000000000..56daa3efdd --- /dev/null +++ b/src/Umbraco.Core/Security/MembershipPasswordHasher.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNet.Identity; + +namespace Umbraco.Core.Security +{ + /// + /// A custom password hasher that conforms to the current password hashing done in Umbraco + /// + internal class MembershipPasswordHasher : IPasswordHasher + { + private readonly MembershipProviderBase _provider; + + public MembershipPasswordHasher(MembershipProviderBase provider) + { + _provider = provider; + } + + public string HashPassword(string password) + { + return _provider.HashPasswordForStorage(password); + } + + public PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword) + { + return _provider.VerifyPassword(providedPassword, hashedPassword) + ? PasswordVerificationResult.Success + : PasswordVerificationResult.Failed; + } + + + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Security/MembershipProviderBase.cs b/src/Umbraco.Core/Security/MembershipProviderBase.cs index ebcd967cc2..e39919d291 100644 --- a/src/Umbraco.Core/Security/MembershipProviderBase.cs +++ b/src/Umbraco.Core/Security/MembershipProviderBase.cs @@ -16,6 +16,19 @@ namespace Umbraco.Core.Security /// public abstract class MembershipProviderBase : MembershipProvider { + + public string HashPasswordForStorage(string password) + { + string salt; + var hashed = EncryptOrHashNewPassword(password, out salt); + return FormatPasswordForStorage(hashed, salt); + } + + public bool VerifyPassword(string password, string hashedPassword) + { + return CheckPassword(password, hashedPassword); + } + /// /// Providers can override this setting, default is 7 /// diff --git a/src/Umbraco.Core/Security/MembershipProviderExtensions.cs b/src/Umbraco.Core/Security/MembershipProviderExtensions.cs index f59f4d1169..bdd3174960 100644 --- a/src/Umbraco.Core/Security/MembershipProviderExtensions.cs +++ b/src/Umbraco.Core/Security/MembershipProviderExtensions.cs @@ -13,21 +13,21 @@ using Umbraco.Core.Security; namespace Umbraco.Core.Security { - internal static class MembershipProviderExtensions + public static class MembershipProviderExtensions { - public static MembershipUserCollection FindUsersByName(this MembershipProvider provider, string usernameToMatch) + internal static MembershipUserCollection FindUsersByName(this MembershipProvider provider, string usernameToMatch) { int totalRecords = 0; return provider.FindUsersByName(usernameToMatch, 0, int.MaxValue, out totalRecords); } - public static MembershipUserCollection FindUsersByEmail(this MembershipProvider provider, string emailToMatch) + internal static MembershipUserCollection FindUsersByEmail(this MembershipProvider provider, string emailToMatch) { int totalRecords = 0; return provider.FindUsersByEmail(emailToMatch, 0, int.MaxValue, out totalRecords); } - public static MembershipUser CreateUser(this MembershipProvider provider, string username, string password, string email) + internal static MembershipUser CreateUser(this MembershipProvider provider, string username, string password, string email) { MembershipCreateStatus status; var user = provider.CreateUser(username, password, email, null, null, true, null, out status); @@ -80,7 +80,7 @@ namespace Umbraco.Core.Security /// /// /// - public static MembershipUser GetCurrentUser(this MembershipProvider membershipProvider) + internal static MembershipUser GetCurrentUser(this MembershipProvider membershipProvider) { var username = membershipProvider.GetCurrentUserName(); return username.IsNullOrWhiteSpace() @@ -93,7 +93,7 @@ namespace Umbraco.Core.Security /// /// /// - public static string GetCurrentUserName(this MembershipProvider membershipProvider) + internal static string GetCurrentUserName(this MembershipProvider membershipProvider) { if (HostingEnvironment.IsHosted) { diff --git a/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs b/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs index 2737c636a3..bf3bc7016c 100644 --- a/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs +++ b/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs @@ -1,115 +1,190 @@ using System; using System.Runtime.Serialization; +using System.Security.Claims; +using System.Security.Principal; using System.Web; using System.Web.Security; using Newtonsoft.Json; namespace Umbraco.Core.Security { + /// /// A custom user identity for the Umbraco backoffice /// /// - /// All values are lazy loaded for performance reasons as the constructor is called for every single request + /// This inherits from FormsIdentity for backwards compatibility reasons since we still support the forms auth cookie, in v8 we can + /// change over to 'pure' asp.net identity and just inherit from ClaimsIdentity. /// [Serializable] public class UmbracoBackOfficeIdentity : FormsIdentity { - public UmbracoBackOfficeIdentity(FormsAuthenticationTicket ticket) + /// + /// Create a back office identity based on user data + /// + /// + public UmbracoBackOfficeIdentity(UserData userdata) + //This just creates a temp/fake ticket + : base(new FormsAuthenticationTicket(userdata.Username, true, 10)) + { + if (userdata == null) throw new ArgumentNullException("userdata"); + UserData = userdata; + AddUserDataClaims(); + } + + /// + /// Create a back office identity based on an existing claims identity + /// + /// + /// + public UmbracoBackOfficeIdentity(ClaimsIdentity claimsIdentity, UserData userdata) + //This just creates a temp/fake ticket + : base(new FormsAuthenticationTicket(userdata.Username, true, 10)) + { + if (claimsIdentity == null) throw new ArgumentNullException("claimsIdentity"); + if (userdata == null) throw new ArgumentNullException("userdata"); + + _currentIssuer = claimsIdentity.AuthenticationType; + UserData = userdata; + AddClaims(claimsIdentity); + Actor = claimsIdentity; + AddUserDataClaims(); + } + + /// + /// Create a new identity from a forms auth ticket + /// + /// + public UmbracoBackOfficeIdentity(FormsAuthenticationTicket ticket) : base(ticket) { - UserData = ticket.UserData; - EnsureDeserialized(); + UserData = JsonConvert.DeserializeObject(ticket.UserData); + AddUserDataClaims(); + } + + /// + /// Used for cloning + /// + /// + private UmbracoBackOfficeIdentity(UmbracoBackOfficeIdentity identity) + : base(identity) + { + if (identity.Actor != null) + { + _currentIssuer = identity.AuthenticationType; + AddClaims(identity); + Actor = identity.Clone(); + } + + UserData = identity.UserData; + AddUserDataClaims(); + } + + public const string Issuer = "UmbracoBackOffice"; + private readonly string _currentIssuer = Issuer; + + private void AddClaims(ClaimsIdentity claimsIdentity) + { + foreach (var claim in claimsIdentity.Claims) + { + AddClaim(claim); + } + } + + /// + /// Adds claims based on the UserData data + /// + private void AddUserDataClaims() + { + AddClaims(new[] + { + //This is the id that 'identity' uses to check for the user id + new Claim(ClaimTypes.NameIdentifier, Id.ToString(), null, Issuer, Issuer, this), + + new Claim(Constants.Security.StartContentNodeIdClaimType, StartContentNode.ToInvariantString(), null, Issuer, Issuer, this), + new Claim(Constants.Security.StartMediaNodeIdClaimType, StartMediaNode.ToInvariantString(), null, Issuer, Issuer, this), + new Claim(Constants.Security.AllowedApplicationsClaimType, string.Join(",", AllowedApplications), null, Issuer, Issuer, this), + + //TODO: Similar one created by the ClaimsIdentityFactory not sure we need this + new Claim(Constants.Security.CultureClaimType, Culture, null, Issuer, Issuer, this) + + //TODO: Role claims are added by the default ClaimsIdentityFactory based on the result from + // the user manager manager.GetRolesAsync method so not sure if we can do that there or needs to be done here + // and each role should be a different claim, not a single string + + //new Claim(ClaimTypes.Role, string.Join(",", Roles), null, Issuer, Issuer, this) + }); + + //TODO: Find out why sessionid is null - this depends on how the identity is created! + if (SessionId.IsNullOrWhiteSpace() == false) + { + AddClaim(new Claim(Constants.Security.SessionIdClaimType, SessionId, null, Issuer, Issuer, this)); + } + + } + + protected internal UserData UserData { get; private set; } + + /// + /// Gets the type of authenticated identity. + /// + /// + /// The type of authenticated identity. This property always returns "UmbracoBackOffice". + /// + public override string AuthenticationType + { + get { return _currentIssuer; } } - - protected readonly string UserData; - internal UserData DeserializedData; public int StartContentNode { - get - { - return DeserializedData.StartContentNode; - } + get { return UserData.StartContentNode; } } public int StartMediaNode { - get { return DeserializedData.StartMediaNode; } + get { return UserData.StartMediaNode; } } public string[] AllowedApplications { - get { return DeserializedData.AllowedApplications; } + get { return UserData.AllowedApplications; } } - + public object Id { - get { return DeserializedData.Id; } + get { return UserData.Id; } } public string RealName { - get { return DeserializedData.RealName; } + get { return UserData.RealName; } } public string Culture { - get { return DeserializedData.Culture; } + get { return UserData.Culture; } } public string SessionId { - get { return DeserializedData.SessionId; } + get { return UserData.SessionId; } } - //public int SessionTimeout - //{ - // get - // { - // EnsureDeserialized(); - // return DeserializedData.SessionTimeout; - // } - //} - public string[] Roles { - get { return DeserializedData.Roles; } + get { return UserData.Roles; } } /// - /// This will ensure we only deserialize once + /// Gets a copy of the current instance. /// - /// - /// For performance reasons, we'll also check if there's an http context available, - /// if so, we'll chuck our instance in there so that we only deserialize once per request. - /// - protected void EnsureDeserialized() + /// + /// A copy of the current instance. + /// + public override ClaimsIdentity Clone() { - if (DeserializedData != null) - return; - - if (HttpContext.Current != null) - { - //check if we've already done this in this request - var data = HttpContext.Current.Items[typeof(UmbracoBackOfficeIdentity)] as UserData; - if (data != null) - { - DeserializedData = data; - return; - } - } - - if (string.IsNullOrEmpty(UserData)) - { - throw new NullReferenceException("The " + typeof(UserData) + " found in the ticket cannot be empty"); - } - DeserializedData = JsonConvert.DeserializeObject(UserData); - - if (HttpContext.Current != null) - { - HttpContext.Current.Items[typeof (UmbracoBackOfficeIdentity)] = DeserializedData; - } + return new UmbracoBackOfficeIdentity(this); } } diff --git a/src/Umbraco.Core/Security/UmbracoMembershipProviderBase.cs b/src/Umbraco.Core/Security/UmbracoMembershipProviderBase.cs index 12ca2b4b4a..328d8ad335 100644 --- a/src/Umbraco.Core/Security/UmbracoMembershipProviderBase.cs +++ b/src/Umbraco.Core/Security/UmbracoMembershipProviderBase.cs @@ -8,6 +8,8 @@ namespace Umbraco.Core.Security /// public abstract class UmbracoMembershipProviderBase : MembershipProviderBase { + + public abstract string DefaultMemberTypeAlias { get; } /// diff --git a/src/Umbraco.Core/Services/ExternalLoginService.cs b/src/Umbraco.Core/Services/ExternalLoginService.cs new file mode 100644 index 0000000000..f33f1c492b --- /dev/null +++ b/src/Umbraco.Core/Services/ExternalLoginService.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.Identity; +using Umbraco.Core.Logging; +using Umbraco.Core.Models.Identity; +using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.Querying; +using Umbraco.Core.Persistence.UnitOfWork; + +namespace Umbraco.Core.Services +{ + public class ExternalLoginService : RepositoryService, IExternalLoginService + { + public ExternalLoginService(IDatabaseUnitOfWorkProvider provider, RepositoryFactory repositoryFactory, ILogger logger) + : base(provider, repositoryFactory, logger) + { + } + + /// + /// Returns all user logins assigned + /// + /// + /// + public IEnumerable GetAll(int userId) + { + using (var repo = RepositoryFactory.CreateExternalLoginRepository(UowProvider.GetUnitOfWork())) + { + return repo.GetByQuery(new Query().Where(x => x.UserId == userId)); + } + } + + /// + /// 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 adminstrator has been editing/removing members + /// + /// + /// + public IEnumerable Find(UserLoginInfo login) + { + using (var repo = RepositoryFactory.CreateExternalLoginRepository(UowProvider.GetUnitOfWork())) + { + return repo.GetByQuery(new Query() + .Where(x => x.ProviderKey == login.ProviderKey && x.LoginProvider == login.LoginProvider)); + } + } + + /// + /// Save user logins + /// + /// + /// + public void SaveUserLogins(int userId, IEnumerable logins) + { + var uow = UowProvider.GetUnitOfWork(); + using (var repo = RepositoryFactory.CreateExternalLoginRepository(uow)) + { + repo.SaveUserLogins(userId, logins); + uow.Commit(); + } + } + + /// + /// Deletes all user logins - normally used when a member is deleted + /// + /// + public void DeleteUserLogins(int userId) + { + var uow = UowProvider.GetUnitOfWork(); + using (var repo = RepositoryFactory.CreateExternalLoginRepository(uow)) + { + repo.DeleteUserLogins(userId); + uow.Commit(); + } + } + + + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Services/IExternalLoginService.cs b/src/Umbraco.Core/Services/IExternalLoginService.cs new file mode 100644 index 0000000000..e1b1a161d8 --- /dev/null +++ b/src/Umbraco.Core/Services/IExternalLoginService.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using Microsoft.AspNet.Identity; +using Umbraco.Core.Models.Identity; + +namespace Umbraco.Core.Services +{ + /// + /// Used to store the external login info, this can be replaced with your own implementation + /// + public interface IExternalLoginService : IService + { + /// + /// Returns all user logins assigned + /// + /// + /// + IEnumerable GetAll(int userId); + + /// + /// 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 adminstrator has been editing/removing members + /// + /// + /// + IEnumerable Find(UserLoginInfo login); + + /// + /// Save user logins + /// + /// + /// + void SaveUserLogins(int userId, IEnumerable logins); + + /// + /// Deletes all user logins - normally used when a member is deleted + /// + /// + void DeleteUserLogins(int userId); + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Services/IFileService.cs b/src/Umbraco.Core/Services/IFileService.cs index 484226ae78..7803abe98e 100644 --- a/src/Umbraco.Core/Services/IFileService.cs +++ b/src/Umbraco.Core/Services/IFileService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Umbraco.Core.Models; diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index 5a77956931..aa8250cf59 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using Umbraco.Core.Models.Membership; namespace Umbraco.Core.Services diff --git a/src/Umbraco.Core/Services/ServiceContext.cs b/src/Umbraco.Core/Services/ServiceContext.cs index f1eb873db7..d72115e49d 100644 --- a/src/Umbraco.Core/Services/ServiceContext.cs +++ b/src/Umbraco.Core/Services/ServiceContext.cs @@ -41,6 +41,7 @@ namespace Umbraco.Core.Services private Lazy _memberTypeService; private Lazy _memberGroupService; private Lazy _notificationService; + private Lazy _externalLoginService; /// /// public ctor - will generally just be used for unit testing all items are optional and if not specified, the defaults will be used @@ -91,8 +92,10 @@ namespace Umbraco.Core.Services IDomainService domainService = null, ITaskService taskService = null, IMacroService macroService = null, - IPublicAccessService publicAccessService = null) + IPublicAccessService publicAccessService = null, + IExternalLoginService externalLoginService = null) { + if (externalLoginService != null) _externalLoginService = new Lazy(() => externalLoginService); if (auditService != null) _auditService = new Lazy(() => auditService); if (localizedTextService != null) _localizedTextService = new Lazy(() => localizedTextService); if (tagService != null) _tagService = new Lazy(() => tagService); @@ -145,6 +148,9 @@ namespace Umbraco.Core.Services var provider = dbUnitOfWorkProvider; var fileProvider = fileUnitOfWorkProvider; + if (_externalLoginService == null) + _externalLoginService = new Lazy(() => new ExternalLoginService(provider, repositoryFactory, logger)); + if (_publicAccessService == null) _publicAccessService = new Lazy(() => new PublicAccessService(provider, repositoryFactory, logger)); @@ -415,5 +421,9 @@ namespace Umbraco.Core.Services get { return _memberGroupService.Value; } } + public IExternalLoginService ExternalLoginService + { + get { return _externalLoginService.Value; } + } } } \ No newline at end of file diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 7cdb815933..523fde9136 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -54,6 +54,30 @@ False ..\packages\log4net-mediumtrust.2.0.0\lib\log4net.dll + + False + ..\packages\Microsoft.AspNet.Identity.Core.2.2.0\lib\net45\Microsoft.AspNet.Identity.Core.dll + + + False + ..\packages\Microsoft.AspNet.Identity.Owin.2.2.0\lib\net45\Microsoft.AspNet.Identity.Owin.dll + + + False + ..\packages\Microsoft.Owin.3.0.1\lib\net45\Microsoft.Owin.dll + + + ..\packages\Microsoft.Owin.Security.3.0.1\lib\net45\Microsoft.Owin.Security.dll + True + + + False + ..\packages\Microsoft.Owin.Security.Cookies.3.0.1\lib\net45\Microsoft.Owin.Security.Cookies.dll + + + False + ..\packages\Microsoft.Owin.Security.OAuth.3.0.1\lib\net45\Microsoft.Owin.Security.OAuth.dll + True ..\packages\Microsoft.Web.Infrastructure.1.0.0.0\lib\net40\Microsoft.Web.Infrastructure.dll @@ -70,6 +94,9 @@ False ..\packages\Newtonsoft.Json.6.0.4\lib\net45\Newtonsoft.Json.dll + + ..\packages\Owin.1.0\lib\net40\Owin.dll + @@ -320,6 +347,13 @@ + + + + + + + @@ -337,15 +371,19 @@ + + + + @@ -372,8 +410,10 @@ + + @@ -381,6 +421,10 @@ + + + + @@ -391,8 +435,10 @@ + + @@ -1102,7 +1148,7 @@ - + diff --git a/src/Umbraco.Core/app.config b/src/Umbraco.Core/app.config index 8f828418f3..1f5a6442ad 100644 --- a/src/Umbraco.Core/app.config +++ b/src/Umbraco.Core/app.config @@ -14,6 +14,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Umbraco.Core/packages.config b/src/Umbraco.Core/packages.config index f3f9b617f7..fd68072884 100644 --- a/src/Umbraco.Core/packages.config +++ b/src/Umbraco.Core/packages.config @@ -3,6 +3,8 @@ + + @@ -10,10 +12,15 @@ + + + + + \ No newline at end of file diff --git a/src/Umbraco.Tests/App.config b/src/Umbraco.Tests/App.config index 3d35ae5c93..f1917d16c0 100644 --- a/src/Umbraco.Tests/App.config +++ b/src/Umbraco.Tests/App.config @@ -152,6 +152,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Umbraco.Web.UI.Client/bower.json b/src/Umbraco.Web.UI.Client/bower.json index e8ad94653f..ed2b0d7332 100644 --- a/src/Umbraco.Web.UI.Client/bower.json +++ b/src/Umbraco.Web.UI.Client/bower.json @@ -19,10 +19,11 @@ "typeahead.js": "~0.10.5", "underscore": "~1.7.0", "rgrove-lazyload": "*", + "bootstrap-social": "~4.8.0", "jquery": "2.0.3", "jquery-file-upload": "~9.4.0", "jquery-ui": "1.10.3", - "angular-dynamic-locale": "~0.1.27" + "angular-dynamic-locale": "~0.1.27" }, "exportsOverride": { "rgrove-lazyload": { @@ -37,8 +38,18 @@ "angular-dynamic-locale": { "": "tmhDynamicLocale.min.{js,js.map}" }, + "bootstrap-social": { + "": "bootstrap-social.css" + }, + "font-awesome": { + "css": "css/font-awesome.min.css", + "fonts": "fonts/*" + }, + "bootstrap": { + "ignore": "*.ignore" + }, "jquery": { - "": "jquery.min.{js,map}" + "": "jquery.min.{js,map}" }, "jquery-file-upload": { "": "**/jquery.{fileupload,fileupload-process,fileupload-angular,fileupload-image}.js" @@ -53,7 +64,7 @@ "blueimp-tmpl": { "ignore": "*.ignore" }, - + "blueimp-canvas-to-blob": { "ignore": "*.ignore" } diff --git a/src/Umbraco.Web.UI.Client/src/app.js b/src/Umbraco.Web.UI.Client/src/app.js index 847fc2c57c..c541e0776c 100644 --- a/src/Umbraco.Web.UI.Client/src/app.js +++ b/src/Umbraco.Web.UI.Client/src/app.js @@ -10,4 +10,12 @@ var app = angular.module('umbraco', [ 'blueimp.fileupload', 'tmh.dynamicLocale' ]); -var packages = angular.module("umbraco.packages", []); \ No newline at end of file +var packages = angular.module("umbraco.packages", []); + +//Call a document callback if defined, this is sort of a dodgy hack to +// be able to configure angular values in the Default.cshtml +// view which is much easier to do that configuring values by injecting them in the back office controller +// to follow through to the js initialization stuff +if (angular.isFunction(document.angularReady)) { + document.angularReady.apply(this, [app]); +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js index e83b35e087..f32602bda6 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js @@ -52,6 +52,24 @@ function authResource($q, $http, umbRequestHelper, angularHelper) { 'Login failed for user ' + username); }, + unlinkLogin: function (loginProvider, providerKey) { + if (!loginProvider || !providerKey) { + return angularHelper.rejectedPromise({ + errorMsg: 'loginProvider or providerKey cannot be empty' + }); + } + + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "authenticationApiBaseUrl", + "PostUnLinkLogin"), { + loginProvider: loginProvider, + providerKey: providerKey + }), + 'Unlinking login provider failed'); + }, + /** * @ngdoc method * @name umbraco.resources.authResource#performLogout @@ -105,6 +123,16 @@ function authResource($q, $http, umbRequestHelper, angularHelper) { "GetCurrentUser")), 'Server call failed for getting current user'); }, + + getCurrentUserLinkedLogins: function () { + + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "authenticationApiBaseUrl", + "GetCurrentUserLinkedLogins")), + 'Server call failed for getting current users linked logins'); + }, /** * @ngdoc method 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 85578dbb99..8d99086ba5 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 @@ -56,7 +56,7 @@ angular.module('umbraco.services') /** Method to count down the current user's timeout seconds, - this will continually count down their current remaining seconds every 2 seconds until + this will continually count down their current remaining seconds every 5 seconds until there are no more seconds remaining. */ function countdownUserTimeout() { @@ -64,8 +64,8 @@ angular.module('umbraco.services') $timeout(function () { if (currentUser) { - //countdown by 2 seconds since that is how long our timer is for. - currentUser.remainingAuthSeconds -= 2; + //countdown by 5 seconds since that is how long our timer is for. + currentUser.remainingAuthSeconds -= 5; //if there are more than 30 remaining seconds, recurse! if (currentUser.remainingAuthSeconds > 30) { @@ -128,7 +128,7 @@ angular.module('umbraco.services') } } } - }, 2000, //every 2 seconds + }, 5000, //every 5 seconds false); //false = do NOT execute a digest for every iteration } diff --git a/src/Umbraco.Web.UI.Client/src/less/login.less b/src/Umbraco.Web.UI.Client/src/less/login.less index d46bb41317..d01dec8733 100644 --- a/src/Umbraco.Web.UI.Client/src/less/login.less +++ b/src/Umbraco.Web.UI.Client/src/less/login.less @@ -2,46 +2,72 @@ // ------------------------- .login-overlay { - width: 100%; - height: 100%; - background: @blackLight url(../img/application/logo.png) no-repeat 25px 30px fixed !important; - background-size: 30px 30px !important; - color: @white; - position: absolute; - z-index: 2000; - top: 0px; - left: 0px; - margin: 0 !Important; - padding: 0; - border-radius: 0 + width: 100%; + height: 100%; + background: @blackLight url(../img/application/logo.png) no-repeat 25px 30px fixed !important; + background-size: 30px 30px !important; + color: @white; + position: absolute; + z-index: 2000; + top: 0px; + left: 0px; + margin: 0 !Important; + padding: 0; + border-radius: 0; } -.login-overlay .umb-modalcolumn{ - background: none; - border: none; +.login-overlay .umb-modalcolumn { + background: none; + border: none; } .login-overlay .form { - display: block; - padding-top: 100px; - padding-left: 165px; - width: 370px; - text-align: right + display: block; + padding-top: 100px; + padding-left: 165px; + width: 370px; + text-align: right; } .login-overlay h1 { - display: block; - text-align: right; - color: @white; - font-size: 18px; - font-weight: normal + display: block; + text-align: right; + color: @white; + font-size: 18px; + font-weight: normal; } -.login-overlay .alert.alert-error{ +.login-overlay .alert.alert-error { display: inline-block; - width: 270px; padding-right: 6px; padding-left: 6px; margin-top: 10px; text-align: center; -} \ No newline at end of file +} + +#hrOr { + height: 30px; + text-align: center; + position: relative; + padding-top: 20px; +} + +#hrOr hr { + margin: 0px; + border: none; + background-color: @gray; + height: 1px; +} + +#hrOr div { + background-color: black; + position: relative; + top: -16px; + border: 1px solid @gray; + padding: 4px; + border-radius: 50%; + width: 20px; + height: 20px; + margin: auto; + color: @grayLight; +} diff --git a/src/Umbraco.Web.UI.Client/src/less/panel.less b/src/Umbraco.Web.UI.Client/src/less/panel.less index 7fc2d061ea..c661fe1223 100644 --- a/src/Umbraco.Web.UI.Client/src/less/panel.less +++ b/src/Umbraco.Web.UI.Client/src/less/panel.less @@ -280,4 +280,11 @@ .umb-dialog a.text-success:hover, .umb-dialog a.text-success:focus, .umb-panel a.text-success:hover, -.umb-panel a.text-success:focus { color: darken(@formSuccessText, 10%); } \ No newline at end of file +.umb-panel a.text-success:focus { color: darken(@formSuccessText, 10%); } + +.external-logins form { + margin:0; +} +.external-logins button { + margin:5px; +} diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js index c9e7adabe2..e73c60685f 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js @@ -1,61 +1,66 @@ -angular.module("umbraco").controller("Umbraco.Dialogs.LoginController", function ($scope, localizationService, userService) { - - /** - * @ngdoc function - * @name signin - * @methodOf MainController - * @function - * - * @description - * signs the user in - */ - var d = new Date(); - //var weekday = new Array("Super Sunday", "Manic Monday", "Tremendous Tuesday", "Wonderful Wednesday", "Thunder Thursday", "Friendly Friday", "Shiny Saturday"); - localizationService.localize("login_greeting"+d.getDay()).then(function(label){ - $scope.greeting = label; - }); // weekday[d.getDay()]; - - $scope.errorMsg = ""; - - $scope.loginSubmit = function (login, password) { - - //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'l just make sure to set them to valid. - if (login && password && login.length > 0 && password.length > 0) { - $scope.loginForm.username.$setValidity('auth', true); - $scope.loginForm.password.$setValidity('auth', true); - } - - - if ($scope.loginForm.$invalid) { - return; - } +angular.module("umbraco").controller("Umbraco.Dialogs.LoginController", + function ($scope, localizationService, userService, externalLoginInfo) { - userService.authenticate(login, password) - .then(function (data) { - $scope.submit(true); - }, function (reason) { - $scope.errorMsg = reason.errorMsg; - - //set the form inputs to invalid - $scope.loginForm.username.$setValidity("auth", false); - $scope.loginForm.password.$setValidity("auth", false); - }); - - //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. - $scope.loginForm.username.$viewChangeListeners.push(function () { - if ($scope.loginForm.username.$invalid) { + /** + * @ngdoc function + * @name signin + * @methodOf MainController + * @function + * + * @description + * signs the user in + */ + var d = new Date(); + //var weekday = new Array("Super Sunday", "Manic Monday", "Tremendous Tuesday", "Wonderful Wednesday", "Thunder Thursday", "Friendly Friday", "Shiny Saturday"); + localizationService.localize("login_greeting" + d.getDay()).then(function (label) { + $scope.greeting = label; + }); // weekday[d.getDay()]; + + $scope.errorMsg = ""; + + $scope.externalLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLoginsUrl; + $scope.externalLoginProviders = externalLoginInfo.providers; + $scope.externalLoginInfo = externalLoginInfo; + + $scope.loginSubmit = function (login, password) { + + //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'l just make sure to set them to valid. + if (login && password && login.length > 0 && password.length > 0) { $scope.loginForm.username.$setValidity('auth', true); - } - }); - $scope.loginForm.password.$viewChangeListeners.push(function () { - if ($scope.loginForm.password.$invalid) { $scope.loginForm.password.$setValidity('auth', true); } - }); - }; -}); + + + if ($scope.loginForm.$invalid) { + return; + } + + userService.authenticate(login, password) + .then(function (data) { + $scope.submit(true); + }, function (reason) { + $scope.errorMsg = reason.errorMsg; + + //set the form inputs to invalid + $scope.loginForm.username.$setValidity("auth", false); + $scope.loginForm.password.$setValidity("auth", false); + }); + + //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. + $scope.loginForm.username.$viewChangeListeners.push(function () { + if ($scope.loginForm.username.$invalid) { + $scope.loginForm.username.$setValidity('auth', true); + } + }); + $scope.loginForm.password.$viewChangeListeners.push(function () { + if ($scope.loginForm.password.$invalid) { + $scope.loginForm.password.$setValidity('auth', true); + } + }); + }; + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html index 865dd8303f..25a8b28976 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html @@ -1,4 +1,4 @@ -
+
@@ -7,20 +7,50 @@ Log in below. Log in below

- -
- -
- -
- -
- - -
-
{{errorMsg}}
+
+ +
+ {{error}} +
+ + + +
+ + + +
+ + +
+
Or
+
+
+ +
+
+ +
+ +
+ +
+ + + +
+
{{errorMsg}}
+
+
+
- \ No newline at end of file +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.controller.js index 5979962128..6048edfa95 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.controller.js @@ -1,10 +1,12 @@ angular.module("umbraco") - .controller("Umbraco.Dialogs.UserController", function ($scope, $location, $timeout, userService, historyService, eventsService) { + .controller("Umbraco.Dialogs.UserController", function ($scope, $location, $timeout, userService, historyService, eventsService, externalLoginInfo, authResource) { - $scope.user = userService.getCurrentUser(); $scope.history = historyService.getCurrent(); $scope.version = Umbraco.Sys.ServerVariables.application.version + " assembly: " + Umbraco.Sys.ServerVariables.application.assemblyVersion; + $scope.externalLoginProviders = externalLoginInfo.providers; + $scope.externalLinkLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLinkLoginsUrl; + var evtHandlers = []; evtHandlers.push(eventsService.on("historyService.add", function (e, args) { $scope.history = args.all; @@ -49,16 +51,49 @@ angular.module("umbraco") }, 1000, false); // 1 second, do NOT execute a global digest } - //get the user - userService.getCurrentUser().then(function (user) { - $scope.user = user; - if ($scope.user) { - $scope.remainingAuthSeconds = $scope.user.remainingAuthSeconds; - $scope.canEditProfile = _.indexOf($scope.user.allowedSections, "users") > -1; - //set the timer - updateTimeout(); + function updateUserInfo() { + //get the user + userService.getCurrentUser().then(function (user) { + $scope.user = user; + if ($scope.user) { + $scope.remainingAuthSeconds = $scope.user.remainingAuthSeconds; + $scope.canEditProfile = _.indexOf($scope.user.allowedSections, "users") > -1; + //set the timer + updateTimeout(); + + authResource.getCurrentUserLinkedLogins().then(function(logins) { + //reset all to be un-linked + for (var provider in $scope.externalLoginProviders) { + $scope.externalLoginProviders[provider].linkedProviderKey = undefined; + } + + //set the linked logins + for (var login in logins) { + var found = _.find($scope.externalLoginProviders, function (i) { + return i.authType == login; + }); + if (found) { + found.linkedProviderKey = logins[login]; + } + } + }); + } + }); + } + + $scope.unlink = function (e, loginProvider, providerKey) { + var result = confirm("Are you sure you want to unlink this account?"); + if (!result) { + e.preventDefault(); + return; } - }); + + authResource.unlinkLogin(loginProvider, providerKey).then(function (a, b, c) { + updateUserInfo(); + }); + } + + updateUserInfo(); //remove all event handlers $scope.$on('$destroy', function () { diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.html b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.html index ff71cec524..8346acfabb 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.html @@ -1,48 +1,81 @@ -
-
-
-
- -
-
-

{{user.name}}

-

- - : {{remainingAuthSeconds | timespan}} -

+
+
+
+
+ +
+
+

{{user.name}}

+

+ + : {{remainingAuthSeconds | timespan}} + +

-
- -
-
- -
-
-

- - Edit - -

-
+
+
+
-
-
- -
+
+
+

+ + Edit + +

+
-
+
- Umbraco version {{version}} -
+
External login providers
+ +
+ +
+ + +
+ + +
+ +
+ +
+
+ +
+ +
+ + Umbraco version {{version}} +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index f51d61b15c..095cf28afc 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -151,7 +151,35 @@ False ..\packages\Lucene.Net.2.9.4.1\lib\net40\Lucene.Net.dll + + ..\packages\Microsoft.AspNet.Identity.Core.2.2.0\lib\net45\Microsoft.AspNet.Identity.Core.dll + True + + + False + ..\packages\Microsoft.AspNet.Identity.Owin.2.2.0\lib\net45\Microsoft.AspNet.Identity.Owin.dll + + + ..\packages\Microsoft.Owin.3.0.1\lib\net45\Microsoft.Owin.dll + True + + + False + ..\packages\Microsoft.Owin.Host.SystemWeb.3.0.1\lib\net45\Microsoft.Owin.Host.SystemWeb.dll + + + False + ..\packages\Microsoft.Owin.Security.3.0.1\lib\net45\Microsoft.Owin.Security.dll + + + False + ..\packages\Microsoft.Owin.Security.Cookies.3.0.1\lib\net45\Microsoft.Owin.Security.Cookies.dll + + + False + ..\packages\Microsoft.Owin.Security.OAuth.3.0.1\lib\net45\Microsoft.Owin.Security.OAuth.dll + ..\packages\Microsoft.Bcl.Async.1.0.165\lib\net45\Microsoft.Threading.Tasks.dll @@ -178,6 +206,10 @@ False ..\packages\Newtonsoft.Json.6.0.4\lib\net45\Newtonsoft.Json.dll + + ..\packages\Owin.1.0\lib\net40\Owin.dll + True + System @@ -199,6 +231,7 @@ + False diff --git a/src/Umbraco.Web.UI/config/ClientDependency.config b/src/Umbraco.Web.UI/config/ClientDependency.config index 246bff7cac..7b53338f12 100644 --- a/src/Umbraco.Web.UI/config/ClientDependency.config +++ b/src/Umbraco.Web.UI/config/ClientDependency.config @@ -10,7 +10,7 @@ NOTES: * Compression/Combination/Minification is not enabled unless debug="false" is specified on the 'compiliation' element in the web.config * A new version will invalidate both client and server cache and create new persisted files --> - +