From 3294ee8e189b98f26d3072d56551acb2aa822596 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Tue, 25 Feb 2020 17:32:39 +0000 Subject: [PATCH 01/34] Addd ASP.NET Core Identity --- src/Umbraco.Web/Umbraco.Web.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 301f1fa4c2..5c75c25c77 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -1,4 +1,4 @@ - + @@ -81,6 +81,7 @@ + From 38ea741d78aea2e0d5f53262db66d0dc2d22af0f Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Tue, 25 Feb 2020 18:00:06 +0000 Subject: [PATCH 02/34] Removed use of Identity.Owin extension methods. Replaced security stamp constant --- src/Umbraco.Core/Constants-Web.cs | 5 +++++ .../UmbracoBackOfficeIdentityTests.cs | 2 +- .../Security/AuthenticationExtensions.cs | 3 +-- .../BackOfficeCookieAuthenticationProvider.cs | 3 +-- .../Security/UmbracoBackOfficeIdentity.cs | 19 +++++++++---------- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Umbraco.Core/Constants-Web.cs b/src/Umbraco.Core/Constants-Web.cs index a1e138116d..4574934939 100644 --- a/src/Umbraco.Core/Constants-Web.cs +++ b/src/Umbraco.Core/Constants-Web.cs @@ -34,6 +34,11 @@ /// The header name that angular uses to pass in the token to validate the cookie /// public const string AngularHeadername = "X-UMB-XSRF-TOKEN"; + + /// + /// The claim type for the ASP.NET Identity security stamp + /// + public const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp"; } } } diff --git a/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs b/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs index beb2c0b3dc..9c16d0c35a 100644 --- a/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs +++ b/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs @@ -38,7 +38,7 @@ namespace Umbraco.Tests.Security new Claim(ClaimTypes.Locality, "en-us", ClaimValueTypes.String, TestIssuer, TestIssuer), new Claim(Constants.Security.SessionIdClaimType, sessionId, Constants.Security.SessionIdClaimType, TestIssuer, TestIssuer), new Claim(ClaimsIdentity.DefaultRoleClaimType, "admin", ClaimValueTypes.String, TestIssuer, TestIssuer), - new Claim(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType, securityStamp, ClaimValueTypes.String, TestIssuer, TestIssuer), + new Claim(Constants.Web.SecurityStampClaimType, securityStamp, ClaimValueTypes.String, TestIssuer, TestIssuer), }); var backofficeIdentity = UmbracoBackOfficeIdentity.FromClaimsIdentity(claimsIdentity); diff --git a/src/Umbraco.Web/Security/AuthenticationExtensions.cs b/src/Umbraco.Web/Security/AuthenticationExtensions.cs index 1fd8e45c55..1c2184728a 100644 --- a/src/Umbraco.Web/Security/AuthenticationExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationExtensions.cs @@ -7,7 +7,6 @@ using System.Security.Claims; using System.Security.Principal; using System.Threading; using System.Web; -using Microsoft.AspNet.Identity; using Microsoft.Owin; using Microsoft.Owin.Security; using Newtonsoft.Json; @@ -231,7 +230,7 @@ namespace Umbraco.Web.Security var claimsIdentity = http.User.Identity as ClaimsIdentity; if (claimsIdentity != null) { - var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + var sessionId = claimsIdentity.FindFirst(Constants.Security.SessionIdClaimType)?.Value; Guid guidSession; if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession)) { diff --git a/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs b/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs index c0390da40a..dc243f969c 100644 --- a/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs +++ b/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs @@ -1,7 +1,6 @@ using System; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.AspNet.Identity; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; using Umbraco.Core; @@ -57,7 +56,7 @@ namespace Umbraco.Web.Security if (context?.OwinContext?.Authentication?.User?.Identity != null) { var claimsIdentity = context.OwinContext.Authentication.User.Identity as ClaimsIdentity; - var sessionId = claimsIdentity.FindFirstValue(Core.Constants.Security.SessionIdClaimType); + var sessionId = claimsIdentity.FindFirst(Core.Constants.Security.SessionIdClaimType)?.Value; if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out var guidSession)) { _userService.ClearLoginSession(guidSession); diff --git a/src/Umbraco.Web/Security/UmbracoBackOfficeIdentity.cs b/src/Umbraco.Web/Security/UmbracoBackOfficeIdentity.cs index 7817e4729f..e2f78546fd 100644 --- a/src/Umbraco.Web/Security/UmbracoBackOfficeIdentity.cs +++ b/src/Umbraco.Web/Security/UmbracoBackOfficeIdentity.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Security.Claims; -using Microsoft.AspNet.Identity; namespace Umbraco.Core.Security { @@ -117,7 +116,7 @@ namespace Umbraco.Core.Security Constants.Security.StartMediaNodeIdClaimType, ClaimTypes.Locality, Constants.Security.SessionIdClaimType, - Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType + Constants.Web.SecurityStampClaimType }; /// @@ -161,8 +160,8 @@ namespace Umbraco.Core.Security //The security stamp claim is also required... this is because this claim type is hard coded // by the SecurityStampValidator, see: https://katanaproject.codeplex.com/workitem/444 - if (HasClaim(x => x.Type == Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType) == false) - AddClaim(new Claim(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType, securityStamp, ClaimValueTypes.String, Issuer, Issuer, this)); + if (HasClaim(x => x.Type == Constants.Web.SecurityStampClaimType) == false) + AddClaim(new Claim(Constants.Web.SecurityStampClaimType, securityStamp, ClaimValueTypes.String, Issuer, Issuer, this)); //Add each app as a separate claim if (HasClaim(x => x.Type == Constants.Security.AllowedApplicationsClaimType) == false && allowedApps != null) @@ -204,17 +203,17 @@ namespace Umbraco.Core.Security private string[] _allowedApplications; public string[] AllowedApplications => _allowedApplications ?? (_allowedApplications = FindAll(x => x.Type == Constants.Security.AllowedApplicationsClaimType).Select(app => app.Value).ToArray()); - public int Id => int.Parse(this.FindFirstValue(ClaimTypes.NameIdentifier)); + public int Id => int.Parse(this.FindFirst(ClaimTypes.NameIdentifier)?.Value); - public string RealName => this.FindFirstValue(ClaimTypes.GivenName); + public string RealName => this.FindFirst(ClaimTypes.GivenName)?.Value; - public string Username => this.GetUserName(); + public string Username => this.FindFirst(ClaimTypes.Name)?.Value; - public string Culture => this.FindFirstValue(ClaimTypes.Locality); + public string Culture => this.FindFirst(ClaimTypes.Locality)?.Value; public string SessionId { - get => this.FindFirstValue(Constants.Security.SessionIdClaimType); + get => this.FindFirst(Constants.Security.SessionIdClaimType)?.Value; set { var existing = FindFirst(Constants.Security.SessionIdClaimType); @@ -224,7 +223,7 @@ namespace Umbraco.Core.Security } } - public string SecurityStamp => this.FindFirstValue(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType); + public string SecurityStamp => this.FindFirst(Constants.Web.SecurityStampClaimType)?.Value; public string[] Roles => this.FindAll(x => x.Type == DefaultRoleClaimType).Select(role => role.Value).ToArray(); From a05a45bb64e32a4556e6290e9dec86c745b3b1be Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Tue, 25 Feb 2020 18:21:07 +0000 Subject: [PATCH 03/34] Added identity stores. TODOs --- src/Umbraco.Web/OwinExtensions.cs | 3 ++- src/Umbraco.Web/Security/BackOfficeSignInManager.cs | 13 +++++++++++++ src/Umbraco.Web/Umbraco.Web.csproj | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web/OwinExtensions.cs b/src/Umbraco.Web/OwinExtensions.cs index 685868a76b..204df8935e 100644 --- a/src/Umbraco.Web/OwinExtensions.cs +++ b/src/Umbraco.Web/OwinExtensions.cs @@ -83,6 +83,7 @@ namespace Umbraco.Web return marker.GetManager(owinContext) ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager)} from the {typeof (IOwinContext)}."); } - } + // TODO: SB: OWIN DI + } } diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs index fe5b061d15..2506613276 100644 --- a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs @@ -16,6 +16,19 @@ using Constants = Umbraco.Core.Constants; namespace Umbraco.Web.Security { + // TODO: SB: Create new custom BackOfficeSignInManager2 (rename pending) + public class BackOfficeSignInManager2 + { + // Create + // CreateUserIdentityAsync + // PasswordSignInAsync + // SignInAsync + // GetVerifiedUserIdAsync + // GetVerifiedUserNameAsync + // TwoFactorSignInAsync + // SendTwoFactorCodeAsync + } + // TODO: In v8 we need to change this to use an int? nullable TKey instead, see notes against overridden TwoFactorSignInAsync public class BackOfficeSignInManager : SignInManager { diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 5c75c25c77..d127783ef2 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -82,6 +82,7 @@ + From 4e69e1abe6b517d101784b045b88087b735e4ac1 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Mon, 9 Mar 2020 16:01:57 +0000 Subject: [PATCH 04/34] Initial user store --- .../Models/Identity/UserLoginInfoWrapper.cs | 24 + .../Security/BackOfficeUserStore2.cs | 915 ++++++++++++++++++ src/Umbraco.Web/Security/IUserSessionStore.cs | 8 +- src/Umbraco.Web/Umbraco.Web.csproj | 5 +- 4 files changed, 948 insertions(+), 4 deletions(-) create mode 100644 src/Umbraco.Web/Security/BackOfficeUserStore2.cs diff --git a/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs b/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs index cd3cd51d3f..0baac36032 100644 --- a/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs +++ b/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs @@ -26,4 +26,28 @@ namespace Umbraco.Web.Models.Identity set => _info.ProviderKey = value; } } + + internal class UserLoginInfoWrapper2 : IUserLoginInfo + { + private readonly Microsoft.AspNetCore.Identity.UserLoginInfo _info; + + public static IUserLoginInfo Wrap(Microsoft.AspNetCore.Identity.UserLoginInfo info) => new UserLoginInfoWrapper2(info); + + private UserLoginInfoWrapper2(Microsoft.AspNetCore.Identity.UserLoginInfo info) + { + _info = info; + } + + public string LoginProvider + { + get => _info.LoginProvider; + set => _info.LoginProvider = value; + } + + public string ProviderKey + { + get => _info.ProviderKey; + set => _info.ProviderKey = value; + } + } } diff --git a/src/Umbraco.Web/Security/BackOfficeUserStore2.cs b/src/Umbraco.Web/Security/BackOfficeUserStore2.cs new file mode 100644 index 0000000000..65de3191de --- /dev/null +++ b/src/Umbraco.Web/Security/BackOfficeUserStore2.cs @@ -0,0 +1,915 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Umbraco.Core; +using Umbraco.Core.Configuration; +using Umbraco.Core.Mapping; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Identity; +using Umbraco.Core.Models.Membership; +using Umbraco.Core.Security; +using Umbraco.Core.Services; +using Umbraco.Web.Models.Identity; +using Constants = Umbraco.Core.Constants; +using IUser = Umbraco.Core.Models.Membership.IUser; +using UserLoginInfo = Microsoft.AspNetCore.Identity.UserLoginInfo; + +namespace Umbraco.Web.Security +{ + public class BackOfficeUserStore2 : DisposableObjectSlim, + IUserPasswordStore, + IUserEmailStore, + IUserLoginStore, + IUserRoleStore, + IUserSecurityStampStore, + IUserLockoutStore, + IUserTwoFactorStore, + IUserSessionStore2 + + // TODO: This would require additional columns/tables for now people will need to implement this on their own + //IUserPhoneNumberStore, + // TODO: To do this we need to implement IQueryable - we'll have an IQuerable implementation soon with the UmbracoLinqPadDriver implementation + //IQueryableUserStore + { + private readonly IUserService _userService; + private readonly IEntityService _entityService; + private readonly IExternalLoginService _externalLoginService; + private readonly IGlobalSettings _globalSettings; + private readonly UmbracoMapper _mapper; + private bool _disposed = false; + + public BackOfficeUserStore2(IUserService userService, IEntityService entityService, IExternalLoginService externalLoginService, IGlobalSettings globalSettings, UmbracoMapper mapper) + { + _userService = userService; + _entityService = entityService; + _externalLoginService = externalLoginService; + _globalSettings = globalSettings; + if (userService == null) throw new ArgumentNullException("userService"); + if (externalLoginService == null) throw new ArgumentNullException("externalLoginService"); + _mapper = mapper; + + _userService = userService; + _externalLoginService = externalLoginService; + } + + /// + /// Handles the disposal of resources. Derived from abstract class which handles common required locking logic. + /// + protected override void DisposeResources() + { + _disposed = true; + } + + public Task GetUserIdAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) + { + + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return Task.FromResult(user.Id.ToString()); + } + + public Task GetUserNameAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return Task.FromResult(user.UserName); + } + + public Task SetUserNameAsync(BackOfficeIdentityUser user, string userName, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + user.UserName = userName; + return Task.CompletedTask; + } + + public Task GetNormalizedUserNameAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) + { + return GetUserNameAsync(user, cancellationToken); + } + + public Task SetNormalizedUserNameAsync(BackOfficeIdentityUser user, string normalizedName, CancellationToken cancellationToken) + { + return SetUserNameAsync(user, normalizedName, cancellationToken); + } + + /// + /// Insert a new user + /// + /// + /// + /// + public Task CreateAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(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. + //this will hash the guid with a salt so should be nicely random + var aspHasher = new Microsoft.AspNet.Identity.PasswordHasher(); + var emptyPasswordValue = Constants.Security.EmptyPasswordPrefix + + aspHasher.HashPassword(Guid.NewGuid().ToString("N")); + + var userEntity = new User(_globalSettings, user.Name, user.Email, user.UserName, emptyPasswordValue) + { + Language = user.Culture ?? _globalSettings.DefaultUILanguage, + StartContentIds = user.StartContentIds ?? new int[] { }, + StartMediaIds = user.StartMediaIds ?? new int[] { }, + IsLockedOut = user.IsLockedOut, + }; + + UpdateMemberProperties(userEntity, user); + + // TODO: We should deal with Roles --> User Groups here which we currently are not doing + + _userService.Save(userEntity); + + if (!userEntity.HasIdentity) throw new DataException("Could not create the user, check logs for details"); + + //re-assign id + user.Id = userEntity.Id; + + return Task.FromResult(IdentityResult.Success); + } + + /// + /// Update a user + /// + /// + /// + /// + public async Task UpdateAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(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) + { + // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. + var isLoginsPropertyDirty = user.IsPropertyDirty("Logins"); + + if (UpdateMemberProperties(found, user)) + { + _userService.Save(found); + } + + if (isLoginsPropertyDirty) + { + var logins = await GetLoginsAsync(user); + _externalLoginService.SaveUserLogins(found.Id, logins.Select(UserLoginInfoWrapper2.Wrap)); + } + } + + return IdentityResult.Success; + } + + /// + /// Delete a user + /// + /// + /// + public Task DeleteAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(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(IdentityResult.Success); + } + + /// + /// Finds a user + /// + /// + /// + /// + public async Task FindByIdAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + var asInt = userId.TryConvertTo(); + if (asInt == false) throw new InvalidOperationException("The user id must be an integer to work with the Umbraco"); + + var user = _userService.GetUserById(asInt.Result); + if (user == null) return null; + + return await Task.FromResult(AssignLoginsCallback(_mapper.Map(user))); + } + + /// + /// Find a user by name + /// + /// + /// + /// + public async Task FindByNameAsync(string userName, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + var user = _userService.GetByUsername(userName); + if (user == null) + { + return null; + } + + var result = AssignLoginsCallback(_mapper.Map(user)); + + return await Task.FromResult(result); + } + + /// + /// Set the user password hash + /// + /// + /// + /// + public Task SetPasswordHashAsync(BackOfficeIdentityUser user, string passwordHash, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + if (passwordHash == null) throw new ArgumentNullException(nameof(passwordHash)); + if (string.IsNullOrEmpty(passwordHash)) throw new ArgumentException("Value can't be empty.", nameof(passwordHash)); + + user.PasswordHash = passwordHash; + + return Task.CompletedTask; + } + + /// + /// Get the user password hash + /// + /// + /// + /// + public Task GetPasswordHashAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return Task.FromResult(user.PasswordHash); + } + + /// + /// Returns true if a user has a password set + /// + /// + /// + /// + public Task HasPasswordAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return Task.FromResult(string.IsNullOrEmpty(user.PasswordHash) == false); + } + + /// + /// Set the user email + /// + /// + /// + /// + public Task SetEmailAsync(BackOfficeIdentityUser user, string email, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + if (email.IsNullOrWhiteSpace()) throw new ArgumentNullException("email"); + + user.Email = email; + + return Task.CompletedTask; + } + + /// + /// Get the user email + /// + /// + /// + /// + public Task GetEmailAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return Task.FromResult(user.Email); + } + + /// + /// Returns true if the user email is confirmed + /// + /// + /// + /// + public Task GetEmailConfirmedAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return Task.FromResult(user.EmailConfirmed); + } + + /// + /// Sets whether the user email is confirmed + /// + /// + /// + /// + public Task SetEmailConfirmedAsync(BackOfficeIdentityUser user, bool confirmed, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + user.EmailConfirmed = confirmed; + return Task.CompletedTask; + } + + /// + /// Returns the user associated with this email + /// + /// + /// + /// + public Task FindByEmailAsync(string email, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + var user = _userService.GetByEmail(email); + var result = user == null + ? null + : _mapper.Map(user); + + return Task.FromResult(AssignLoginsCallback(result)); + } + + public Task GetNormalizedEmailAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) + { + return GetEmailAsync(user, cancellationToken); + } + + public Task SetNormalizedEmailAsync(BackOfficeIdentityUser user, string normalizedEmail, CancellationToken cancellationToken) + { + return SetEmailAsync(user, normalizedEmail, cancellationToken); + } + + /// + /// Adds a user login with the specified provider and key + /// + /// + /// + /// + /// + public Task AddLoginAsync(BackOfficeIdentityUser user, UserLoginInfo login, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + if (login == null) throw new ArgumentNullException(nameof(login)); + + var logins = user.Logins; + var instance = new IdentityUserLogin(login.LoginProvider, login.ProviderKey, user.Id); + var userLogin = instance; + logins.Add(userLogin); + + return Task.CompletedTask; + } + + /// + /// Removes the user login with the specified combination if it exists + /// + /// + /// + /// + /// + /// + public Task RemoveLoginAsync(BackOfficeIdentityUser user, string loginProvider, string providerKey, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + // TODO: SCOTT: Consider adding display name to IIdentityUserLogin + var userLogin = user.Logins.SingleOrDefault(l => l.LoginProvider == loginProvider && l.ProviderKey == providerKey); + if (userLogin != null) user.Logins.Remove(userLogin); + + return Task.CompletedTask; + } + + /// + /// Returns the linked accounts for this user + /// + /// + /// + /// + public Task> GetLoginsAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + return Task.FromResult((IList) + user.Logins.Select(l => new UserLoginInfo(l.LoginProvider, l.ProviderKey, l.LoginProvider)).ToList()); + } + + /// + /// Returns the user associated with this login + /// + /// + public Task FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + //get all logins associated with the login id + var result = _externalLoginService.Find(UserLoginInfoWrapper2.Wrap(new UserLoginInfo(loginProvider, providerKey, loginProvider))).ToArray(); + if (result.Any()) + { + //return the first user that matches the result + BackOfficeIdentityUser output = null; + foreach (var l in result) + { + var user = _userService.GetUserById(l.UserId); + if (user != null) + { + output = _mapper.Map(user); + break; + } + } + + return Task.FromResult(AssignLoginsCallback(output)); + } + + return Task.FromResult(null); + } + + + /// + /// Adds a user to a role (user group) + /// + /// + /// + /// + /// + public Task AddToRoleAsync(BackOfficeIdentityUser user, string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + if (normalizedRoleName == null) throw new ArgumentNullException(nameof(normalizedRoleName)); + if (string.IsNullOrWhiteSpace(normalizedRoleName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(normalizedRoleName)); + + var userRole = user.Roles.SingleOrDefault(r => r.RoleId == normalizedRoleName); + + if (userRole == null) + { + user.AddRole(normalizedRoleName); + } + + return Task.CompletedTask; + } + + /// + /// Removes the role (user group) for the user + /// + /// + /// + /// + /// + public Task RemoveFromRoleAsync(BackOfficeIdentityUser user, string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (user == null) throw new ArgumentNullException(nameof(user)); + if (normalizedRoleName == null) throw new ArgumentNullException(nameof(normalizedRoleName)); + if (string.IsNullOrWhiteSpace(normalizedRoleName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(normalizedRoleName)); + + var userRole = user.Roles.SingleOrDefault(r => r.RoleId == normalizedRoleName); + + if (userRole != null) + { + user.Roles.Remove(userRole); + } + + return Task.CompletedTask; + } + + /// + /// Returns the roles (user groups) for this user + /// + /// + /// + /// + public Task> GetRolesAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + return Task.FromResult((IList)user.Roles.Select(x => x.RoleId).ToList()); + } + + /// + /// Returns true if a user is in the role + /// + /// + /// + /// + /// + public Task IsInRoleAsync(BackOfficeIdentityUser user, string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + return Task.FromResult(user.Roles.Select(x => x.RoleId).InvariantContains(normalizedRoleName)); + } + + public Task> GetUsersInRoleAsync(string normalizedRoleName, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + /// + /// Set the security stamp for the user + /// + /// + /// + /// + /// + public Task SetSecurityStampAsync(BackOfficeIdentityUser user, string stamp, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + user.SecurityStamp = stamp; + return Task.CompletedTask; + } + + /// + /// Get the user security stamp + /// + /// + /// + /// + public Task GetSecurityStampAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(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.GenerateHash() + : user.SecurityStamp); + } + + private BackOfficeIdentityUser AssignLoginsCallback(BackOfficeIdentityUser user) + { + if (user != null) + { + user.SetLoginsCallback(new Lazy>(() => + _externalLoginService.GetAll(user.Id))); + } + return user; + } + + /// + /// Sets whether two factor authentication is enabled for the user + /// + /// + /// + /// + /// + public virtual Task SetTwoFactorEnabledAsync(BackOfficeIdentityUser user, bool enabled, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + user.TwoFactorEnabled = false; + return Task.CompletedTask; + } + + /// + /// Returns whether two factor authentication is enabled for the user + /// + /// + /// + public virtual Task GetTwoFactorEnabledAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + return Task.FromResult(false); + } + + #region IUserLockoutStore + + /// + /// Returns the DateTimeOffset that represents the end of a user's lockout, any time in the past should be considered not locked out. + /// + /// + /// + /// + /// + /// Currently we do not support a timed lock out, when they are locked out, an admin will have to reset the status + /// + public Task GetLockoutEndDateAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return user.LockoutEndDateUtc.HasValue + ? Task.FromResult(DateTimeOffset.MaxValue) + : Task.FromResult(DateTimeOffset.MinValue); + } + + /// + /// Locks a user out until the specified end date (set to a past date, to unlock a user) + /// + /// + /// + /// + /// + /// Currently we do not support a timed lock out, when they are locked out, an admin will have to reset the status + /// + public Task SetLockoutEndDateAsync(BackOfficeIdentityUser user, DateTimeOffset? lockoutEnd, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + user.LockoutEndDateUtc = lockoutEnd.Value.UtcDateTime; + return Task.CompletedTask; + } + + /// + /// Used to record when an attempt to access the user has failed + /// + /// + /// + /// + public Task IncrementAccessFailedCountAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + user.AccessFailedCount++; + return Task.FromResult(user.AccessFailedCount); + } + + /// + /// Used to reset the access failed count, typically after the account is successfully accessed + /// + /// + /// + /// + public Task ResetAccessFailedCountAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + user.AccessFailedCount = 0; + return Task.CompletedTask; + } + + /// + /// Returns the current number of failed access attempts. This number usually will be reset whenever the password is + /// verified or the account is locked out. + /// + /// + /// + /// + public Task GetAccessFailedCountAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + return Task.FromResult(user.AccessFailedCount); + } + + /// + /// Returns true + /// + /// + /// + /// + public Task GetLockoutEnabledAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + return Task.FromResult(user.LockoutEnabled); + } + + /// + /// Doesn't actually perform any function, users can always be locked out + /// + /// + /// + /// + public Task SetLockoutEnabledAsync(BackOfficeIdentityUser user, bool enabled, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + user.LockoutEnabled = enabled; + return Task.CompletedTask; + } + #endregion + + private bool UpdateMemberProperties(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 (identityUser.IsPropertyDirty("LastLoginDateUtc") + || (user.LastLoginDate != default(DateTime) && identityUser.LastLoginDateUtc.HasValue == false) + || identityUser.LastLoginDateUtc.HasValue && user.LastLoginDate.ToUniversalTime() != identityUser.LastLoginDateUtc.Value) + { + anythingChanged = true; + //if the LastLoginDate is being set to MinValue, don't convert it ToLocalTime + var dt = identityUser.LastLoginDateUtc == DateTime.MinValue ? DateTime.MinValue : identityUser.LastLoginDateUtc.Value.ToLocalTime(); + user.LastLoginDate = dt; + } + if (identityUser.IsPropertyDirty("LastPasswordChangeDateUtc") + || (user.LastPasswordChangeDate != default(DateTime) && identityUser.LastPasswordChangeDateUtc.HasValue == false) + || identityUser.LastPasswordChangeDateUtc.HasValue && user.LastPasswordChangeDate.ToUniversalTime() != identityUser.LastPasswordChangeDateUtc.Value) + { + anythingChanged = true; + user.LastPasswordChangeDate = identityUser.LastPasswordChangeDateUtc.Value.ToLocalTime(); + } + if (identityUser.IsPropertyDirty("EmailConfirmed") + || (user.EmailConfirmedDate.HasValue && user.EmailConfirmedDate.Value != default(DateTime) && identityUser.EmailConfirmed == false) + || ((user.EmailConfirmedDate.HasValue == false || user.EmailConfirmedDate.Value == default(DateTime)) && identityUser.EmailConfirmed)) + { + anythingChanged = true; + user.EmailConfirmedDate = identityUser.EmailConfirmed ? (DateTime?)DateTime.Now : null; + } + if (identityUser.IsPropertyDirty("Name") + && user.Name != identityUser.Name && identityUser.Name.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + user.Name = identityUser.Name; + } + if (identityUser.IsPropertyDirty("Email") + && user.Email != identityUser.Email && identityUser.Email.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + user.Email = identityUser.Email; + } + if (identityUser.IsPropertyDirty("AccessFailedCount") + && user.FailedPasswordAttempts != identityUser.AccessFailedCount) + { + anythingChanged = true; + user.FailedPasswordAttempts = identityUser.AccessFailedCount; + } + if (user.IsLockedOut != identityUser.IsLockedOut) + { + anythingChanged = true; + user.IsLockedOut = identityUser.IsLockedOut; + + if (user.IsLockedOut) + { + //need to set the last lockout date + user.LastLockoutDate = DateTime.Now; + } + + } + if (identityUser.IsPropertyDirty("UserName") + && user.Username != identityUser.UserName && identityUser.UserName.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + user.Username = identityUser.UserName; + } + if (identityUser.IsPropertyDirty("PasswordHash") + && user.RawPasswordValue != identityUser.PasswordHash && identityUser.PasswordHash.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + user.RawPasswordValue = identityUser.PasswordHash; + } + + if (identityUser.IsPropertyDirty("Culture") + && user.Language != identityUser.Culture && identityUser.Culture.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + user.Language = identityUser.Culture; + } + if (identityUser.IsPropertyDirty("StartMediaIds") + && user.StartMediaIds.UnsortedSequenceEqual(identityUser.StartMediaIds) == false) + { + anythingChanged = true; + user.StartMediaIds = identityUser.StartMediaIds; + } + if (identityUser.IsPropertyDirty("StartContentIds") + && user.StartContentIds.UnsortedSequenceEqual(identityUser.StartContentIds) == false) + { + anythingChanged = true; + user.StartContentIds = identityUser.StartContentIds; + } + if (user.SecurityStamp != identityUser.SecurityStamp) + { + anythingChanged = true; + user.SecurityStamp = identityUser.SecurityStamp; + } + + // TODO: Fix this for Groups too + if (identityUser.IsPropertyDirty("Roles") || identityUser.IsPropertyDirty("Groups")) + { + var userGroupAliases = user.Groups.Select(x => x.Alias).ToArray(); + + var identityUserRoles = identityUser.Roles.Select(x => x.RoleId).ToArray(); + var identityUserGroups = identityUser.Groups.Select(x => x.Alias).ToArray(); + + var combinedAliases = identityUserRoles.Union(identityUserGroups).ToArray(); + + if (userGroupAliases.ContainsAll(combinedAliases) == false + || combinedAliases.ContainsAll(userGroupAliases) == false) + { + anythingChanged = true; + + //clear out the current groups (need to ToArray since we are modifying the iterator) + user.ClearGroups(); + + //go lookup all these groups + var groups = _userService.GetUserGroupsByAlias(combinedAliases).Select(x => x.ToReadOnlyGroup()).ToArray(); + + //use all of the ones assigned and add them + foreach (var group in groups) + { + user.AddGroup(group); + } + + //re-assign + identityUser.Groups = groups; + } + } + + //we should re-set the calculated start nodes + identityUser.CalculatedMediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService); + identityUser.CalculatedContentStartNodeIds = user.CalculateContentStartNodeIds(_entityService); + + //reset all changes + identityUser.ResetDirtyProperties(false); + + return anythingChanged; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(GetType().Name); + } + + public Task ValidateSessionIdAsync(int userId, string sessionId) + { + Guid guidSessionId; + if (Guid.TryParse(sessionId, out guidSessionId)) + { + return Task.FromResult(_userService.ValidateLoginSession(userId, guidSessionId)); + } + return Task.FromResult(false); + } + } +} diff --git a/src/Umbraco.Web/Security/IUserSessionStore.cs b/src/Umbraco.Web/Security/IUserSessionStore.cs index 3454b19f84..524017d4ad 100644 --- a/src/Umbraco.Web/Security/IUserSessionStore.cs +++ b/src/Umbraco.Web/Security/IUserSessionStore.cs @@ -14,4 +14,10 @@ namespace Umbraco.Core.Security { Task ValidateSessionIdAsync(int userId, string sessionId); } -} \ No newline at end of file + + public interface IUserSessionStore2 : Microsoft.AspNetCore.Identity.IUserStore, IDisposable + where TUser : class + { + Task ValidateSessionIdAsync(int userId, string sessionId); + } +} diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index d127783ef2..caa38e56d8 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -1,4 +1,4 @@ - + @@ -198,6 +198,7 @@ + @@ -606,6 +607,4 @@ - - \ No newline at end of file From 2e7e6bc4a9ea543b4bfac0d0caca50ea077d7bfd Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Fri, 13 Mar 2020 15:15:14 +0000 Subject: [PATCH 05/34] Initial side by side implementation --- src/Umbraco.Web/ClaimsIdentityExtensions.cs | 37 ++ src/Umbraco.Web/OwinExtensions.cs | 46 +- .../Security/BackOfficeSignInManager.cs | 13 - .../Security/BackOfficeSignInManager2.cs | 354 ++++++++++ .../Security/BackOfficeUserManager2.cs | 615 ++++++++++++++++++ .../Security/BackOfficeUserValidator2.cs | 20 + .../Security/IBackOfficeUserManagerMarker.cs | 9 + .../Security/UserAwarePasswordHasher2.cs | 40 ++ src/Umbraco.Web/Umbraco.Web.csproj | 5 + .../WebApi/UmbracoAuthorizedApiController.cs | 9 +- 10 files changed, 1127 insertions(+), 21 deletions(-) create mode 100644 src/Umbraco.Web/ClaimsIdentityExtensions.cs create mode 100644 src/Umbraco.Web/Security/BackOfficeSignInManager2.cs create mode 100644 src/Umbraco.Web/Security/BackOfficeUserManager2.cs create mode 100644 src/Umbraco.Web/Security/BackOfficeUserValidator2.cs create mode 100644 src/Umbraco.Web/Security/UserAwarePasswordHasher2.cs diff --git a/src/Umbraco.Web/ClaimsIdentityExtensions.cs b/src/Umbraco.Web/ClaimsIdentityExtensions.cs new file mode 100644 index 0000000000..c7f777f60b --- /dev/null +++ b/src/Umbraco.Web/ClaimsIdentityExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Security.Claims; +using System.Security.Principal; + +namespace Umbraco.Web +{ + public static class ClaimsIdentityExtensions + { + public static string GetUserId(this IIdentity identity) + { + if (identity == null) throw new ArgumentNullException(nameof(identity)); + + string userId = null; + if (identity is ClaimsIdentity claimsIdentity) + { + userId = claimsIdentity.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? claimsIdentity.FindFirst("sub")?.Value; + } + + return userId; + } + + public static string GetUserName(this IIdentity identity) + { + if (identity == null) throw new ArgumentNullException(nameof(identity)); + + string username = null; + if (identity is ClaimsIdentity claimsIdentity) + { + username = claimsIdentity.FindFirst(ClaimTypes.Name)?.Value + ?? claimsIdentity.FindFirst("preferred_username")?.Value; + } + + return username; + } + } +} diff --git a/src/Umbraco.Web/OwinExtensions.cs b/src/Umbraco.Web/OwinExtensions.cs index 204df8935e..25edda2647 100644 --- a/src/Umbraco.Web/OwinExtensions.cs +++ b/src/Umbraco.Web/OwinExtensions.cs @@ -1,11 +1,8 @@ using System; using System.Web; -using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin; using Microsoft.Owin.Security; using Umbraco.Core; -using Umbraco.Core.Models.Identity; -using Umbraco.Core.Security; using Umbraco.Web.Models.Identity; using Umbraco.Web.Security; @@ -66,6 +63,17 @@ namespace Umbraco.Web ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager)} from the {typeof(IOwinContext)}."); } + /// + /// Gets the back office sign in manager out of OWIN + /// + /// + /// + public static BackOfficeSignInManager2 GetBackOfficeSignInManager2(this IOwinContext owinContext) + { + return owinContext.Get() + ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager2)} from the {typeof(IOwinContext)}."); + } + /// /// Gets the back office user manager out of OWIN /// @@ -84,6 +92,38 @@ namespace Umbraco.Web ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager)} from the {typeof (IOwinContext)}."); } + /// + /// Gets the back office user manager out of OWIN + /// + /// + /// + /// + /// This is required because to extract the user manager we need to user a custom service since owin only deals in generics and + /// developers could register their own user manager types + /// + public static BackOfficeUserManager2 GetBackOfficeUserManager2(this IOwinContext owinContext) + { + var marker = owinContext.Get(BackOfficeUserManager2.OwinMarkerKey) + ?? throw new NullReferenceException($"No {typeof (IBackOfficeUserManagerMarker2)}, i.e. no Umbraco back-office, has been registered with Owin."); + + return marker.GetManager(owinContext) + ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager)} from the {typeof (IOwinContext)}."); + } + // TODO: SB: OWIN DI + + /// + /// Adapted from Microsoft.AspNet.Identity.Owin.OwinContextExtensions + /// + public static T Get(this IOwinContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + return context.Get(GetKey(typeof(T))); + } + + private static string GetKey(Type t) + { + return "AspNet.Identity.Owin:" + t.AssemblyQualifiedName; + } } } diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs index 2506613276..fe5b061d15 100644 --- a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs @@ -16,19 +16,6 @@ using Constants = Umbraco.Core.Constants; namespace Umbraco.Web.Security { - // TODO: SB: Create new custom BackOfficeSignInManager2 (rename pending) - public class BackOfficeSignInManager2 - { - // Create - // CreateUserIdentityAsync - // PasswordSignInAsync - // SignInAsync - // GetVerifiedUserIdAsync - // GetVerifiedUserNameAsync - // TwoFactorSignInAsync - // SendTwoFactorCodeAsync - } - // TODO: In v8 we need to change this to use an int? nullable TKey instead, see notes against overridden TwoFactorSignInAsync public class BackOfficeSignInManager : SignInManager { diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs new file mode 100644 index 0000000000..ac33eb208d --- /dev/null +++ b/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs @@ -0,0 +1,354 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Owin; +using Microsoft.Owin.Logging; +using Microsoft.Owin.Security; +using Umbraco.Core; +using Umbraco.Core.Configuration; +using Umbraco.Core.Security; +using Umbraco.Web.Models.Identity; + +namespace Umbraco.Web.Security +{ + /// + /// Custom sign in manager due to SignInManager not being .NET Standard. + /// Can be removed once the web project moves to .NET Core. + /// + public class BackOfficeSignInManager2 + { + private readonly BackOfficeUserManager2 _userManager; + private readonly IAuthenticationManager _authenticationManager; + private readonly ILogger _logger; + private readonly IGlobalSettings _globalSettings; + private readonly IOwinRequest _request; + + public BackOfficeSignInManager2( + BackOfficeUserManager2 userManager, + IAuthenticationManager authenticationManager, + ILogger logger, + IGlobalSettings globalSettings, + IOwinRequest request) + { + _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); + _authenticationManager = authenticationManager ?? throw new ArgumentNullException(nameof(authenticationManager)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _globalSettings = globalSettings ?? throw new ArgumentNullException(nameof(globalSettings)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + } + + public Task CreateUserIdentityAsync(BackOfficeIdentityUser user) + { + throw new NotImplementedException(); + } + + public static BackOfficeSignInManager2 Create(IOwinContext context, IGlobalSettings globalSettings, ILogger logger) + { + return new BackOfficeSignInManager2( + context.GetBackOfficeUserManager2(), + context.Authentication, + logger, + globalSettings, + context.Request); + } + + /// + /// Sign in the user in using the user name and password + /// + /// + /// + public async Task PasswordSignInAsync(string userName, string password, bool isPersistent, bool shouldLockout) + { + var result = await PasswordSignInAsyncImpl(userName, password, isPersistent, shouldLockout); + + if (result.Succeeded) + { + _logger.WriteCore(TraceEventType.Information, 0, + $"User: {userName} logged in from IP address {_request.RemoteIpAddress}", null, null); + } + else if (result.IsLockedOut) + { + _logger.WriteCore(TraceEventType.Information, 0, + $"Login attempt failed for username {userName} from IP address {_request.RemoteIpAddress}, the user is locked", null, null); + } + else if (result.RequiresTwoFactor) + { + _logger.WriteCore(TraceEventType.Information, 0, + $"Login attempt requires verification for username {userName} from IP address {_request.RemoteIpAddress}", null, null); + } + else if (!result.Succeeded || result.IsNotAllowed) + { + _logger.WriteCore(TraceEventType.Information, 0, + $"Login attempt failed for username {userName} from IP address {_request.RemoteIpAddress}", null, null); + } + else + { + throw new ArgumentOutOfRangeException(); + } + + return result; + } + + /// + /// Borrowed from Microsoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type + /// + /// + /// + /// + /// + /// + private async Task PasswordSignInAsyncImpl(string userName, string password, bool isPersistent, bool shouldLockout) + { + var user = await _userManager.FindByNameAsync(userName); + + //if the user is null, create an empty one which can be used for auto-linking + if (user == null) user = BackOfficeIdentityUser.CreateNew(_globalSettings, userName, null, _globalSettings.DefaultUILanguage); + + //check the password for the user, this will allow a developer to auto-link + //an account if they have specified an IBackOfficeUserPasswordChecker + if (await _userManager.CheckPasswordAsync(user, password)) + { + //the underlying call to this will query the user by Id which IS cached! + if (await _userManager.IsLockedOutAsync(user)) + { + return SignInResult.LockedOut; + } + + // We need to verify that the user belongs to one or more groups that define content and media start nodes. + // To do so we have to create the user claims identity and validate the calculated start nodes. + var userIdentity = await CreateUserIdentityAsync(user); + if (userIdentity is UmbracoBackOfficeIdentity backOfficeIdentity) + { + if (backOfficeIdentity.StartContentNodes.Length == 0 || backOfficeIdentity.StartMediaNodes.Length == 0) + { + _logger.WriteCore(TraceEventType.Information, 0, + $"Login attempt failed for username {userName} from IP address {_request.RemoteIpAddress}, no content and/or media start nodes could be found for any of the user's groups", null, null); + return SignInResult.Failed; + } + } + + await _userManager.ResetAccessFailedCountAsync(user); + return await SignInOrTwoFactor(user, isPersistent); + } + + var requestContext = _request.Context; + + if (user.HasIdentity && shouldLockout) + { + // If lockout is requested, increment access failed count which might lock out the user + await _userManager.AccessFailedAsync(user); + if (await _userManager.IsLockedOutAsync(user)) + { + //at this point we've just locked the user out after too many failed login attempts + + if (requestContext != null) + { + var backofficeUserManager = requestContext.GetBackOfficeUserManager(); + if (backofficeUserManager != null) backofficeUserManager.RaiseAccountLockedEvent(user.Id); + } + + return SignInResult.LockedOut; + } + } + + if (requestContext != null) + { + var backofficeUserManager = requestContext.GetBackOfficeUserManager(); + if (backofficeUserManager != null) + backofficeUserManager.RaiseInvalidLoginAttemptEvent(userName); + } + + return SignInResult.Failed; + } + + /// + /// Borrowed from Microsoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type + /// + /// + /// + /// + private async Task SignInOrTwoFactor(BackOfficeIdentityUser user, bool isPersistent) + { + var id = Convert.ToString(user.Id); + if (await _userManager.GetTwoFactorEnabledAsync(user) + && (await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0) + { + var identity = new ClaimsIdentity(Constants.Security.BackOfficeTwoFactorAuthenticationType); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, id)); + identity.AddClaim(new Claim(ClaimsIdentity.DefaultNameClaimType, user.UserName)); + _authenticationManager.SignIn(identity); + return SignInResult.TwoFactorRequired; + } + await SignInAsync(user, isPersistent, false); + return SignInResult.Success; + } + + /// + /// Creates a user identity and then signs the identity using the AuthenticationManager + /// + /// + /// + /// + /// + public async Task SignInAsync(BackOfficeIdentityUser user, bool isPersistent, bool rememberBrowser) + { + var userIdentity = await CreateUserIdentityAsync(user); + + // Clear any partial cookies from external or two factor partial sign ins + _authenticationManager.SignOut( + Constants.Security.BackOfficeExternalAuthenticationType, + Constants.Security.BackOfficeTwoFactorAuthenticationType); + + var nowUtc = DateTime.Now.ToUniversalTime(); + + if (rememberBrowser) + { + var rememberBrowserIdentity = _authenticationManager.CreateTwoFactorRememberBrowserIdentity(ConvertIdToString(user.Id)); + _authenticationManager.SignIn(new AuthenticationProperties() + { + IsPersistent = isPersistent, + AllowRefresh = true, + IssuedUtc = nowUtc, + ExpiresUtc = nowUtc.AddMinutes(_globalSettings.TimeOutInMinutes) + }, userIdentity, rememberBrowserIdentity); + } + else + { + _authenticationManager.SignIn(new AuthenticationProperties() + { + IsPersistent = isPersistent, + AllowRefresh = true, + IssuedUtc = nowUtc, + ExpiresUtc = nowUtc.AddMinutes(_globalSettings.TimeOutInMinutes) + }, userIdentity); + } + + //track the last login date + user.LastLoginDateUtc = DateTime.UtcNow; + if (user.AccessFailedCount > 0) + //we have successfully logged in, reset the AccessFailedCount + user.AccessFailedCount = 0; + await _userManager.UpdateAsync(user); + + //set the current request's principal to the identity just signed in! + _request.User = new ClaimsPrincipal(userIdentity); + + _logger.WriteCore(TraceEventType.Information, 0, + string.Format( + "Login attempt succeeded for username {0} from IP address {1}", + user.UserName, + _request.RemoteIpAddress), null, null); + } + + /// + /// Get the user id that has been verified already or int.MinValue if the user has not been verified yet + /// + /// + /// + /// Replaces the underlying call which is not flexible and doesn't support a custom cookie + /// + public async Task GetVerifiedUserIdAsync() + { + var result = await _authenticationManager.AuthenticateAsync(Constants.Security.BackOfficeTwoFactorAuthenticationType); + if (result != null && result.Identity != null && string.IsNullOrEmpty(result.Identity.GetUserId()) == false) + { + return ConvertIdFromString(result.Identity.GetUserId()); + } + return int.MinValue; + } + + /// + /// Get the username that has been verified already or null. + /// + /// + public async Task GetVerifiedUserNameAsync() + { + var result = await _authenticationManager.AuthenticateAsync(Constants.Security.BackOfficeTwoFactorAuthenticationType); + if (result != null && result.Identity != null && string.IsNullOrEmpty(result.Identity.GetUserName()) == false) + { + return result.Identity.GetUserName(); + } + return null; + } + + /// + /// Two factor verification step + /// + /// + /// + /// + /// + /// + /// + /// This is implemented because we cannot override GetVerifiedUserIdAsync and instead we have to shadow it + /// so due to this and because we are using an INT as the TKey and not an object, it can never be null. Adding to that + /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate + /// all of this code to check for int.MinValue + /// + public async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberBrowser) + { + var userId = await GetVerifiedUserIdAsync(); + if (userId == int.MinValue) + { + return SignInResult.Failed; + } + var user = await _userManager.FindByIdAsync(ConvertIdToString(userId)); + if (user == null) + { + return SignInResult.Failed; + } + if (await _userManager.IsLockedOutAsync(user)) + { + return SignInResult.LockedOut; + } + if (await _userManager.VerifyTwoFactorTokenAsync(user, provider, code)) + { + // When token is verified correctly, clear the access failed count used for lockout + await _userManager.ResetAccessFailedCountAsync(user); + await SignInAsync(user, isPersistent, rememberBrowser); + return SignInResult.Success; + } + + // If the token is incorrect, record the failure which also may cause the user to be locked out + await _userManager.AccessFailedAsync(user); + return SignInResult.Failed; + } + + /// Send a two factor code to a user + /// + /// + /// + /// This is implemented because we cannot override GetVerifiedUserIdAsync and instead we have to shadow it + /// so due to this and because we are using an INT as the TKey and not an object, it can never be null. Adding to that + /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate + /// all of this code to check for int.MinVale instead. + /// + public async Task SendTwoFactorCodeAsync(string provider) + { + throw new NotImplementedException(); + + /*var userId = await GetVerifiedUserIdAsync(); + if (userId == int.MinValue) + return false; + + var token = await _userManager.GenerateTwoFactorTokenAsync(userId, provider); + + + var identityResult = await _userManager.NotifyTwoFactorTokenAsync(userId, provider, token); + return identityResult.Succeeded;*/ + } + + private string ConvertIdToString(int id) + { + return Convert.ToString(id, CultureInfo.InvariantCulture); + } + + private int ConvertIdFromString(string id) + { + return id == null ? default(int) : (int) Convert.ChangeType(id, typeof(int), CultureInfo.InvariantCulture); + } + } +} diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs new file mode 100644 index 0000000000..b22f9523c0 --- /dev/null +++ b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs @@ -0,0 +1,615 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Core.Configuration; +using Umbraco.Core.Mapping; +using Umbraco.Core.Security; +using Umbraco.Core.Services; +using Umbraco.Net; +using Umbraco.Web.Models.Identity; + +namespace Umbraco.Web.Security +{ + public class BackOfficeUserManager2 : BackOfficeUserManager2 + { + public const string OwinMarkerKey = "Umbraco.Web.Security.Identity.BackOfficeUserManagerMarker"; + + public BackOfficeUserManager2( + IPasswordConfiguration passwordConfiguration, + IIpResolver ipResolver, + IUserStore store, + IOptions optionsAccessor, + IPasswordHasher passwordHasher, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + IServiceProvider services, + ILogger> logger) + : base(passwordConfiguration, ipResolver, store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) + { + InitUserManager(this, passwordConfiguration); + } + + #region Static Create methods + + // TODO: SB: Static Create methods for OWIN + + /// + /// Creates a BackOfficeUserManager instance with all default options and the default BackOfficeUserManager + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static BackOfficeUserManager2 Create( + IUserService userService, + IEntityService entityService, + IExternalLoginService externalLoginService, + IGlobalSettings globalSettings, + UmbracoMapper mapper, + IPasswordConfiguration passwordConfiguration, + IIpResolver ipResolver, + IOptions optionsAccessor, + IPasswordHasher passwordHasher, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + IServiceProvider services, + ILogger> logger) + { + var store = new BackOfficeUserStore2(userService, entityService, externalLoginService, globalSettings, mapper); + return new BackOfficeUserManager2( + passwordConfiguration, + ipResolver, + store, + optionsAccessor, + passwordHasher, + userValidators, + passwordValidators, + keyNormalizer, + errors, + services, + logger); + } + + /// + /// Creates a BackOfficeUserManager instance with all default options and a custom BackOfficeUserManager instance + /// + /// + public static BackOfficeUserManager2 Create( + IPasswordConfiguration passwordConfiguration, + IIpResolver ipResolver, + IUserStore store, + IOptions optionsAccessor, + IPasswordHasher passwordHasher, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + IServiceProvider services, + ILogger> logger) + { + return new BackOfficeUserManager2( + passwordConfiguration, + ipResolver, + store, + optionsAccessor, + passwordHasher, + userValidators, + passwordValidators, + keyNormalizer, + errors, + services, + logger); + } + + #endregion + } + + public class BackOfficeUserManager2 : UserManager + where T : BackOfficeIdentityUser + { + private PasswordGenerator _passwordGenerator; + + public BackOfficeUserManager2( + IPasswordConfiguration passwordConfiguration, + IIpResolver ipResolver, + IUserStore store, + IOptions optionsAccessor, + IPasswordHasher passwordHasher, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + IServiceProvider services, + ILogger> logger) + : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) + { + PasswordConfiguration = passwordConfiguration ?? throw new ArgumentNullException(nameof(passwordConfiguration)); + IpResolver = ipResolver ?? throw new ArgumentNullException(nameof(ipResolver)); + } + + #region What we do not currently support + // TODO: We could support this - but a user claims will mostly just be what is in the auth cookie + public override bool SupportsUserClaim => false; + + // TODO: Support this + public override bool SupportsQueryableUsers => false; + + /// + /// Developers will need to override this to support custom 2 factor auth + /// + public override bool SupportsUserTwoFactor => false; + + // TODO: Support this + public override bool SupportsUserPhoneNumber => false; + #endregion + + // TODO: SB: INIT + /// + /// Initializes the user manager with the correct options + /// + /// + /// + /// + protected void InitUserManager( + BackOfficeUserManager2 manager, + IPasswordConfiguration passwordConfig) + // IDataProtectionProvider dataProtectionProvider + { + // Configure validation logic for usernames + manager.UserValidators.Clear(); + manager.UserValidators.Add(new BackOfficeUserValidator2()); + manager.Options.User.RequireUniqueEmail = true; + + // Configure validation logic for passwords + manager.PasswordValidators.Clear(); + manager.PasswordValidators.Add(new PasswordValidator()); + manager.Options.Password.RequiredLength = passwordConfig.RequiredLength; + manager.Options.Password.RequireNonAlphanumeric = passwordConfig.RequireNonLetterOrDigit; + manager.Options.Password.RequireDigit = passwordConfig.RequireDigit; + manager.Options.Password.RequireLowercase = passwordConfig.RequireLowercase; + manager.Options.Password.RequireUppercase = passwordConfig.RequireUppercase; + + //use a custom hasher based on our membership provider + manager.PasswordHasher = GetDefaultPasswordHasher(passwordConfig); + + // TODO: SB: manager.Options.Tokens using OWIN data protector + /*if (dataProtectionProvider != null) + { + manager.UserTokenProvider = new DataProtectorTokenProvider(dataProtectionProvider.Create("ASP.NET Identity")) + { + TokenLifespan = TimeSpan.FromDays(3) + }; + }*/ + + manager.Options.Lockout.AllowedForNewUsers = true; + manager.Options.Lockout.MaxFailedAccessAttempts = passwordConfig.MaxFailedAccessAttemptsBeforeLockout; + //NOTE: This just needs to be in the future, we currently don't support a lockout timespan, it's either they are locked + // or they are not locked, but this determines what is set on the account lockout date which corresponds to whether they are + // locked out or not. + manager.Options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(30); + } + + /// + /// Used to validate a user's session + /// + /// + /// + /// + public virtual async Task ValidateSessionIdAsync(int userId, string sessionId) + { + var userSessionStore = Store as IUserSessionStore2; + //if this is not set, for backwards compat (which would be super rare), we'll just approve it + if (userSessionStore == null) return true; + + return await userSessionStore.ValidateSessionIdAsync(userId, sessionId); + } + + /// + /// This will determine which password hasher to use based on what is defined in config + /// + /// + protected virtual IPasswordHasher GetDefaultPasswordHasher(IPasswordConfiguration passwordConfiguration) + { + //we can use the user aware password hasher (which will be the default and preferred way) + return new UserAwarePasswordHasher2(new PasswordSecurity(passwordConfiguration)); + } + + + /// + /// Gets/sets the default back office user password checker + /// + public IBackOfficeUserPasswordChecker BackOfficeUserPasswordChecker { get; set; } + public IPasswordConfiguration PasswordConfiguration { get; } + public IIpResolver IpResolver { get; } + + /// + /// Helper method to generate a password for a user based on the current password validator + /// + /// + public string GeneratePassword() + { + if (_passwordGenerator == null) _passwordGenerator = new PasswordGenerator(PasswordConfiguration); + var password = _passwordGenerator.GeneratePassword(); + return password; + } + + /// + /// Override to check the user approval value as well as the user lock out date, by default this only checks the user's locked out date + /// + /// + /// + /// + /// In the ASP.NET Identity world, there is only one value for being locked out, in Umbraco we have 2 so when checking this for Umbraco we need to check both values + /// + public override async Task IsLockedOutAsync(T user) + { + if (user == null) throw new ArgumentNullException(nameof(user)); + + if (user.IsApproved == false) return true; + + return await base.IsLockedOutAsync(user); + } + + #region Overrides for password logic + + /// + /// Logic used to validate a username and password + /// + /// + /// + /// + /// + /// By default this uses the standard ASP.Net Identity approach which is: + /// * Get password store + /// * Call VerifyPasswordAsync with the password store + user + password + /// * Uses the PasswordHasher.VerifyHashedPassword to compare the stored password + /// + /// In some cases people want simple custom control over the username/password check, for simplicity + /// sake, developers would like the users to simply validate against an LDAP directory but the user + /// data remains stored inside of Umbraco. + /// See: http://issues.umbraco.org/issue/U4-7032 for the use cases. + /// + /// We've allowed this check to be overridden with a simple callback so that developers don't actually + /// have to implement/override this class. + /// + public override async Task CheckPasswordAsync(T user, string password) + { + if (BackOfficeUserPasswordChecker != null) + { + var result = await BackOfficeUserPasswordChecker.CheckPasswordAsync(user, password); + + if (user.HasIdentity == false) + { + return false; + } + + //if the result indicates to not fallback to the default, then return true if the credentials are valid + if (result != BackOfficeUserPasswordCheckerResult.FallbackToDefaultChecker) + { + return result == BackOfficeUserPasswordCheckerResult.ValidCredentials; + } + } + + //we cannot proceed if the user passed in does not have an identity + if (user.HasIdentity == false) + return false; + + //use the default behavior + return await base.CheckPasswordAsync(user, password); + } + + /// + /// This is a special method that will reset the password but will raise the Password Changed event instead of the reset event + /// + /// + /// + /// + /// + /// + /// We use this because in the back office the only way an admin can change another user's password without first knowing their password + /// is to generate a token and reset it, however, when we do this we want to track a password change, not a password reset + /// + public async Task ChangePasswordWithResetAsync(int userId, string token, string newPassword) + { + var user = await base.FindByIdAsync(userId.ToString()); + var result = await base.ResetPasswordAsync(user, token, newPassword); + if (result.Succeeded) RaisePasswordChangedEvent(userId); + return result; + } + + public override async Task ChangePasswordAsync(T user, string currentPassword, string newPassword) + { + var result = await base.ChangePasswordAsync(user, currentPassword, newPassword); + if (result.Succeeded) RaisePasswordChangedEvent(user.Id); + return result; + } + + /// + /// Override to determine how to hash the password + /// + /// + /// + /// + /// + protected override async Task VerifyPasswordAsync(IUserPasswordStore store, T user, string password) + { + var userAwarePasswordHasher = PasswordHasher; + if (userAwarePasswordHasher == null) + return await base.VerifyPasswordAsync(store, user, password); + + var hash = await store.GetPasswordHashAsync(user, CancellationToken.None); + return userAwarePasswordHasher.VerifyHashedPassword(user, hash, password); + } + + /// + /// Override to determine how to hash the password + /// + /// + /// + /// + /// + /// + /// This method is called anytime the password needs to be hashed for storage (i.e. including when reset password is used) + /// + protected override async Task UpdatePasswordHash(T user, string newPassword, bool validatePassword) + { + user.LastPasswordChangeDateUtc = DateTime.UtcNow; + + if (validatePassword) + { + var validate = await ValidatePasswordAsync(user, newPassword); + if (!validate.Succeeded) + { + return validate; + } + } + + var passwordStore = Store as IUserPasswordStore; + if (passwordStore == null) throw new NotSupportedException("The current user store does not implement " + typeof(IUserPasswordStore<>)); + + var hash = newPassword != null ? PasswordHasher.HashPassword(user, newPassword) : null; + await passwordStore.SetPasswordHashAsync(user, hash, CancellationToken); + await UpdateSecurityStampInternal(user); + return IdentityResult.Success; + } + + /// + /// This is copied from the underlying .NET base class since they decided to not expose it + /// + /// + /// + private async Task UpdateSecurityStampInternal(T user) + { + if (SupportsUserSecurityStamp == false) return; + await GetSecurityStore().SetSecurityStampAsync(user, NewSecurityStamp(), CancellationToken.None); + } + + /// + /// This is copied from the underlying .NET base class since they decided to not expose it + /// + /// + private IUserSecurityStampStore GetSecurityStore() + { + var store = Store as IUserSecurityStampStore; + if (store == null) throw new NotSupportedException("The current user store does not implement " + typeof(IUserSecurityStampStore<>)); + return store; + } + + /// + /// This is copied from the underlying .NET base class since they decided to not expose it + /// + /// + private static string NewSecurityStamp() + { + return Guid.NewGuid().ToString(); + } + + #endregion + + public override async Task SetLockoutEndDateAsync(T user, DateTimeOffset? lockoutEnd) + { + if (user == null) throw new ArgumentNullException(nameof(user)); + + var result = await base.SetLockoutEndDateAsync(user, lockoutEnd); + + // The way we unlock is by setting the lockoutEnd date to the current datetime + if (result.Succeeded && lockoutEnd >= DateTimeOffset.UtcNow) + { + RaiseAccountLockedEvent(user.Id); + } + else + { + RaiseAccountUnlockedEvent(user.Id); + //Resets the login attempt fails back to 0 when unlock is clicked + await ResetAccessFailedCountAsync(user); + } + + return result; + } + + public override async Task ResetAccessFailedCountAsync(T user) + { + if (user == null) throw new ArgumentNullException(nameof(user)); + + var lockoutStore = (IUserLockoutStore)Store; + var accessFailedCount = await GetAccessFailedCountAsync(user); + + if (accessFailedCount == 0) + return IdentityResult.Success; + + await lockoutStore.ResetAccessFailedCountAsync(user, CancellationToken.None); + //raise the event now that it's reset + RaiseResetAccessFailedCountEvent(user.Id); + return await UpdateAsync(user); + } + + /// + /// Overrides the Microsoft ASP.NET user management method + /// + /// + /// + /// returns a Async Task + /// + /// + /// Doesn't set fail attempts back to 0 + /// + public override async Task AccessFailedAsync(T user) + { + if (user == null) throw new ArgumentNullException(nameof(user)); + + var lockoutStore = Store as IUserLockoutStore; + if (lockoutStore == null) throw new NotSupportedException("The current user store does not implement " + typeof(IUserLockoutStore<>)); + + var count = await lockoutStore.IncrementAccessFailedCountAsync(user, CancellationToken.None); + + if (count >= Options.Lockout.MaxFailedAccessAttempts) + { + await lockoutStore.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.Add(Options.Lockout.DefaultLockoutTimeSpan), + CancellationToken.None); + //NOTE: in normal aspnet identity this would do set the number of failed attempts back to 0 + //here we are persisting the value for the back office + } + + var result = await UpdateAsync(user); + + //Slightly confusing: this will return a Success if we successfully update the AccessFailed count + if (result.Succeeded) RaiseLoginFailedEvent(user.Id); + + return result; + } + + internal void RaiseAccountLockedEvent(int userId) + { + OnAccountLocked(new IdentityAuditEventArgs(AuditEvent.AccountLocked, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); + } + + internal void RaiseAccountUnlockedEvent(int userId) + { + OnAccountUnlocked(new IdentityAuditEventArgs(AuditEvent.AccountUnlocked, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); + } + + internal void RaiseForgotPasswordRequestedEvent(int userId) + { + OnForgotPasswordRequested(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordRequested, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); + } + + internal void RaiseForgotPasswordChangedSuccessEvent(int userId) + { + OnForgotPasswordChangedSuccess(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordChangedSuccess, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); + } + + internal void RaiseLoginFailedEvent(int userId) + { + OnLoginFailed(new IdentityAuditEventArgs(AuditEvent.LoginFailed, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); + } + + internal void RaiseInvalidLoginAttemptEvent(string username) + { + OnLoginFailed(new IdentityAuditEventArgs(AuditEvent.LoginFailed, IpResolver.GetCurrentRequestIpAddress(), username, string.Format("Attempted login for username '{0}' failed", username))); + } + + internal void RaiseLoginRequiresVerificationEvent(int userId) + { + OnLoginRequiresVerification(new IdentityAuditEventArgs(AuditEvent.LoginRequiresVerification, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); + } + + internal void RaiseLoginSuccessEvent(int userId) + { + OnLoginSuccess(new IdentityAuditEventArgs(AuditEvent.LoginSucces, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); + } + + internal void RaiseLogoutSuccessEvent(int userId) + { + OnLogoutSuccess(new IdentityAuditEventArgs(AuditEvent.LogoutSuccess, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); + } + + internal void RaisePasswordChangedEvent(int userId) + { + OnPasswordChanged(new IdentityAuditEventArgs(AuditEvent.PasswordChanged, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); + } + + internal void RaiseResetAccessFailedCountEvent(int userId) + { + OnResetAccessFailedCount(new IdentityAuditEventArgs(AuditEvent.ResetAccessFailedCount, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); + } + + public static event EventHandler AccountLocked; + public static event EventHandler AccountUnlocked; + public static event EventHandler ForgotPasswordRequested; + public static event EventHandler ForgotPasswordChangedSuccess; + public static event EventHandler LoginFailed; + public static event EventHandler LoginRequiresVerification; + public static event EventHandler LoginSuccess; + public static event EventHandler LogoutSuccess; + public static event EventHandler PasswordChanged; + public static event EventHandler PasswordReset; + public static event EventHandler ResetAccessFailedCount; + + protected virtual void OnAccountLocked(IdentityAuditEventArgs e) + { + if (AccountLocked != null) AccountLocked(this, e); + } + + protected virtual void OnAccountUnlocked(IdentityAuditEventArgs e) + { + if (AccountUnlocked != null) AccountUnlocked(this, e); + } + + protected virtual void OnForgotPasswordRequested(IdentityAuditEventArgs e) + { + if (ForgotPasswordRequested != null) ForgotPasswordRequested(this, e); + } + + protected virtual void OnForgotPasswordChangedSuccess(IdentityAuditEventArgs e) + { + if (ForgotPasswordChangedSuccess != null) ForgotPasswordChangedSuccess(this, e); + } + + protected virtual void OnLoginFailed(IdentityAuditEventArgs e) + { + if (LoginFailed != null) LoginFailed(this, e); + } + + protected virtual void OnLoginRequiresVerification(IdentityAuditEventArgs e) + { + if (LoginRequiresVerification != null) LoginRequiresVerification(this, e); + } + + protected virtual void OnLoginSuccess(IdentityAuditEventArgs e) + { + if (LoginSuccess != null) LoginSuccess(this, e); + } + + protected virtual void OnLogoutSuccess(IdentityAuditEventArgs e) + { + if (LogoutSuccess != null) LogoutSuccess(this, e); + } + + protected virtual void OnPasswordChanged(IdentityAuditEventArgs e) + { + if (PasswordChanged != null) PasswordChanged(this, e); + } + + protected virtual void OnPasswordReset(IdentityAuditEventArgs e) + { + if (PasswordReset != null) PasswordReset(this, e); + } + + protected virtual void OnResetAccessFailedCount(IdentityAuditEventArgs e) + { + if (ResetAccessFailedCount != null) ResetAccessFailedCount(this, e); + } + } +} diff --git a/src/Umbraco.Web/Security/BackOfficeUserValidator2.cs b/src/Umbraco.Web/Security/BackOfficeUserValidator2.cs new file mode 100644 index 0000000000..b6438d730d --- /dev/null +++ b/src/Umbraco.Web/Security/BackOfficeUserValidator2.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Umbraco.Web.Models.Identity; + +namespace Umbraco.Web.Security +{ + public class BackOfficeUserValidator2 : UserValidator + where T : BackOfficeIdentityUser + { + public override async Task ValidateAsync(UserManager manager, T user) + { + // Don't validate if the user's email or username hasn't changed otherwise it's just wasting SQL queries. + if (user.IsPropertyDirty("Email") || user.IsPropertyDirty("UserName")) + { + return await base.ValidateAsync(manager, user); + } + return IdentityResult.Success; + } + } +} diff --git a/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs b/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs index b1f3530879..92fea0bf40 100644 --- a/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs +++ b/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs @@ -13,4 +13,13 @@ namespace Umbraco.Web.Security { BackOfficeUserManager GetManager(IOwinContext owin); } + /// + /// This interface is only here due to the fact that IOwinContext Get / Set only work in generics, if they worked + /// with regular 'object' then we wouldn't have to use this work around but because of that we have to use this + /// class to resolve the 'real' type of the registered user manager + /// + internal interface IBackOfficeUserManagerMarker2 + { + BackOfficeUserManager2 GetManager(IOwinContext owin); + } } diff --git a/src/Umbraco.Web/Security/UserAwarePasswordHasher2.cs b/src/Umbraco.Web/Security/UserAwarePasswordHasher2.cs new file mode 100644 index 0000000000..9a44533427 --- /dev/null +++ b/src/Umbraco.Web/Security/UserAwarePasswordHasher2.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Identity; +using Umbraco.Core.Security; +using Umbraco.Web.Models.Identity; + +namespace Umbraco.Web.Security +{ + public class UserAwarePasswordHasher2 : IPasswordHasher + where T : BackOfficeIdentityUser + { + private readonly PasswordSecurity _passwordSecurity; + + public UserAwarePasswordHasher2(PasswordSecurity passwordSecurity) + { + _passwordSecurity = passwordSecurity; + } + + public string HashPassword(string password) + { + return _passwordSecurity.HashPasswordForStorage(password); + } + + public string HashPassword(T user, string password) + { + // TODO: Implement the logic for this, we need to lookup the password format for the user and hash accordingly: http://issues.umbraco.org/issue/U4-10089 + //NOTE: For now this just falls back to the hashing we are currently using + + return HashPassword(password); + } + + public PasswordVerificationResult VerifyHashedPassword(T user, string hashedPassword, string providedPassword) + { + // TODO: Implement the logic for this, we need to lookup the password format for the user and hash accordingly: http://issues.umbraco.org/issue/U4-10089 + //NOTE: For now this just falls back to the hashing we are currently using + + return _passwordSecurity.VerifyPassword(providedPassword, hashedPassword) + ? PasswordVerificationResult.Success + : PasswordVerificationResult.Failed; + } + } +} diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index caa38e56d8..aed9a34b82 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -135,6 +135,7 @@ + @@ -197,9 +198,12 @@ + + + @@ -211,6 +215,7 @@ + diff --git a/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs b/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs index ff24a2a6f5..985343946e 100644 --- a/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs +++ b/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs @@ -2,8 +2,7 @@ using Umbraco.Core.Cache; using Umbraco.Core.Configuration; using Umbraco.Core.Logging; -using Umbraco.Web.WebApi.Filters; -using Umbraco.Core.Models.Identity; +using Umbraco.Web.WebApi.Filters;using Umbraco.Core.Models.Identity; using Umbraco.Core.Persistence; using Umbraco.Core.Services; using Umbraco.Web.Models.Identity; @@ -31,7 +30,7 @@ namespace Umbraco.Web.WebApi [EnableDetailedErrors] public abstract class UmbracoAuthorizedApiController : UmbracoApiController { - private BackOfficeUserManager _userManager; + private BackOfficeUserManager2 _userManager; protected UmbracoAuthorizedApiController() { @@ -45,7 +44,7 @@ namespace Umbraco.Web.WebApi /// /// Gets the user manager. /// - protected BackOfficeUserManager UserManager - => _userManager ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager()); + protected BackOfficeUserManager2 UserManager + => _userManager ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager2()); } } From db87a9dd40ab0f4e84492f8b0f69712cccfa0025 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Mon, 16 Mar 2020 13:53:03 +0000 Subject: [PATCH 06/34] Compiling with minimal Microsoft.AspNet.Identity references --- .../BackOfficeUserAuditEventsComponent.cs | 22 +- .../Editors/AuthenticationController.cs | 188 ++--- .../Editors/BackOfficeController.cs | 45 +- .../Editors/CurrentUserController.cs | 3 +- src/Umbraco.Web/Editors/PasswordChanger.cs | 22 +- src/Umbraco.Web/Editors/UsersController.cs | 43 +- .../Install/InstallSteps/NewInstallStep.cs | 6 +- .../Models/Identity/UserLoginInfoWrapper.cs | 27 +- src/Umbraco.Web/OwinExtensions.cs | 14 +- .../Security/AppBuilderExtensions.cs | 90 +- .../AuthenticationManagerExtensions.cs | 31 +- .../Security/BackOfficeSignInManager.cs | 337 -------- .../Security/BackOfficeSignInManager2.cs | 7 +- .../Security/BackOfficeUserManager.cs | 641 --------------- .../Security/BackOfficeUserManager2.cs | 17 +- .../Security/BackOfficeUserManagerMarker.cs | 13 +- .../Security/BackOfficeUserStore.cs | 776 ------------------ .../Security/BackOfficeUserStore2.cs | 11 +- .../Security/BackOfficeUserValidator.cs | 28 - .../Security/ExternalSignInAutoLinkOptions.cs | 14 +- .../Security/IBackOfficeUserManagerMarker.cs | 2 +- .../Security/IUserAwarePasswordHasher.cs | 18 - src/Umbraco.Web/Security/IUserSessionStore.cs | 14 +- .../Security/SessionIdValidator.cs | 9 +- .../Security/UserAwarePasswordHasher.cs | 47 -- src/Umbraco.Web/Security/WebSecurity.cs | 18 +- src/Umbraco.Web/Umbraco.Web.csproj | 8 +- src/Umbraco.Web/UmbracoDefaultOwinStartup.cs | 3 +- 28 files changed, 283 insertions(+), 2171 deletions(-) delete mode 100644 src/Umbraco.Web/Security/BackOfficeSignInManager.cs delete mode 100644 src/Umbraco.Web/Security/BackOfficeUserManager.cs delete mode 100644 src/Umbraco.Web/Security/BackOfficeUserStore.cs delete mode 100644 src/Umbraco.Web/Security/BackOfficeUserValidator.cs delete mode 100644 src/Umbraco.Web/Security/IUserAwarePasswordHasher.cs delete mode 100644 src/Umbraco.Web/Security/UserAwarePasswordHasher.cs diff --git a/src/Umbraco.Web/Compose/BackOfficeUserAuditEventsComponent.cs b/src/Umbraco.Web/Compose/BackOfficeUserAuditEventsComponent.cs index 84fb0e6bb8..7efa803c52 100644 --- a/src/Umbraco.Web/Compose/BackOfficeUserAuditEventsComponent.cs +++ b/src/Umbraco.Web/Compose/BackOfficeUserAuditEventsComponent.cs @@ -24,17 +24,17 @@ namespace Umbraco.Web.Compose public void Initialize() { - //BackOfficeUserManager.AccountLocked += ; - //BackOfficeUserManager.AccountUnlocked += ; - BackOfficeUserManager.ForgotPasswordRequested += OnForgotPasswordRequest; - BackOfficeUserManager.ForgotPasswordChangedSuccess += OnForgotPasswordChange; - BackOfficeUserManager.LoginFailed += OnLoginFailed; - //BackOfficeUserManager.LoginRequiresVerification += ; - BackOfficeUserManager.LoginSuccess += OnLoginSuccess; - BackOfficeUserManager.LogoutSuccess += OnLogoutSuccess; - BackOfficeUserManager.PasswordChanged += OnPasswordChanged; - BackOfficeUserManager.PasswordReset += OnPasswordReset; - //BackOfficeUserManager.ResetAccessFailedCount += ; + //BackOfficeUserManager2.AccountLocked += ; + //BackOfficeUserManager2.AccountUnlocked += ; + BackOfficeUserManager2.ForgotPasswordRequested += OnForgotPasswordRequest; + BackOfficeUserManager2.ForgotPasswordChangedSuccess += OnForgotPasswordChange; + BackOfficeUserManager2.LoginFailed += OnLoginFailed; + //BackOfficeUserManager2.LoginRequiresVerification += ; + BackOfficeUserManager2.LoginSuccess += OnLoginSuccess; + BackOfficeUserManager2.LogoutSuccess += OnLogoutSuccess; + BackOfficeUserManager2.PasswordChanged += OnPasswordChanged; + BackOfficeUserManager2.PasswordReset += OnPasswordReset; + //BackOfficeUserManager2.ResetAccessFailedCount += ; } public void Terminate() diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index dbfb6f81a4..99be05c861 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -8,8 +8,7 @@ using System.Threading.Tasks; using System.Web; using System.Web.Http; using System.Web.Mvc; -using Microsoft.AspNet.Identity; -using Microsoft.AspNet.Identity.Owin; +using Microsoft.AspNetCore.Identity; using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Models; @@ -41,8 +40,8 @@ namespace Umbraco.Web.Editors [IsBackOffice] public class AuthenticationController : UmbracoApiController { - private BackOfficeUserManager _userManager; - private BackOfficeSignInManager _signInManager; + private BackOfficeUserManager2 _userManager; + private BackOfficeSignInManager2 _signInManager; private readonly IUserPasswordConfiguration _passwordConfiguration; private readonly IRuntimeState _runtimeState; private readonly IUmbracoSettingsSection _umbracoSettingsSection; @@ -70,11 +69,11 @@ namespace Umbraco.Web.Editors _ioHelper = ioHelper ?? throw new ArgumentNullException(nameof(ioHelper)); } - protected BackOfficeUserManager UserManager => _userManager - ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager()); + protected BackOfficeUserManager2 UserManager => _userManager + ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager2()); - protected BackOfficeSignInManager SignInManager => _signInManager - ?? (_signInManager = TryGetOwinContext().Result.GetBackOfficeSignInManager()); + protected BackOfficeSignInManager2 SignInManager => _signInManager + ?? (_signInManager = TryGetOwinContext().Result.GetBackOfficeSignInManager2()); /// /// Returns the configuration for the backoffice user membership provider - used to configure the change password dialog @@ -105,11 +104,11 @@ namespace Umbraco.Web.Editors if (decoded.IsNullOrWhiteSpace()) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); - var identityUser = await UserManager.FindByIdAsync(id); + var identityUser = await UserManager.FindByIdAsync(id.ToString()); if (identityUser == null) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); - var result = await UserManager.ConfirmEmailAsync(id, decoded); + var result = await UserManager.ConfirmEmailAsync(identityUser, decoded); if (result.Succeeded == false) { @@ -131,13 +130,15 @@ namespace Umbraco.Web.Editors [ValidateAngularAntiForgeryToken] public async Task PostUnLinkLogin(UnLinkLoginModel unlinkLoginModel) { + var user = await UserManager.FindByIdAsync(User.Identity.GetUserId()); + var result = await UserManager.RemoveLoginAsync( - User.Identity.GetUserId(), - new UserLoginInfo(unlinkLoginModel.LoginProvider, unlinkLoginModel.ProviderKey)); + user, + unlinkLoginModel.LoginProvider, + unlinkLoginModel.ProviderKey); if (result.Succeeded) { - var user = await UserManager.FindByIdAsync(User.Identity.GetUserId()); await SignInManager.SignInAsync(user, isPersistent: true, rememberBrowser: false); return Request.CreateResponse(HttpStatusCode.OK); } @@ -225,7 +226,7 @@ namespace Umbraco.Web.Editors [ValidateAngularAntiForgeryToken] public async Task> GetCurrentUserLinkedLogins() { - var identityUser = await UserManager.FindByIdAsync(UmbracoContext.Security.GetUserId().ResultOr(0)); + var identityUser = await UserManager.FindByIdAsync(UmbracoContext.Security.GetUserId().ResultOr(0).ToString()); return identityUser.Logins.ToDictionary(x => x.LoginProvider, x => x.ProviderKey); } @@ -244,61 +245,58 @@ namespace Umbraco.Web.Editors var result = await SignInManager.PasswordSignInAsync( loginModel.Username, loginModel.Password, isPersistent: true, shouldLockout: true); - switch (result) + if (result.Succeeded) { - case SignInStatus.Success: + // get the user + var user = Services.UserService.GetByUsername(loginModel.Username); + UserManager.RaiseLoginSuccessEvent(user.Id); - // get the user - var user = Services.UserService.GetByUsername(loginModel.Username); - UserManager.RaiseLoginSuccessEvent(user.Id); - - return SetPrincipalAndReturnUserDetail(user, owinContext.Request.User); - case SignInStatus.RequiresVerification: - - var twofactorOptions = UserManager as IUmbracoBackOfficeTwoFactorOptions; - if (twofactorOptions == null) - { - throw new HttpResponseException( - Request.CreateErrorResponse( - HttpStatusCode.BadRequest, - "UserManager does not implement " + typeof(IUmbracoBackOfficeTwoFactorOptions))); - } - - var twofactorView = twofactorOptions.GetTwoFactorView( - owinContext, - UmbracoContext, - loginModel.Username); - - if (twofactorView.IsNullOrWhiteSpace()) - { - throw new HttpResponseException( - Request.CreateErrorResponse( - HttpStatusCode.BadRequest, - typeof(IUmbracoBackOfficeTwoFactorOptions) + ".GetTwoFactorView returned an empty string")); - } - - var attemptedUser = Services.UserService.GetByUsername(loginModel.Username); - - // create a with information to display a custom two factor send code view - var verifyResponse = Request.CreateResponse(HttpStatusCode.PaymentRequired, new - { - twoFactorView = twofactorView, - userId = attemptedUser.Id - }); - - UserManager.RaiseLoginRequiresVerificationEvent(attemptedUser.Id); - - return verifyResponse; - - case SignInStatus.LockedOut: - case SignInStatus.Failure: - default: - // return BadRequest (400), we don't want to return a 401 because that get's intercepted - // by our angular helper because it thinks that we need to re-perform the request once we are - // authorized and we don't want to return a 403 because angular will show a warning message indicating - // that the user doesn't have access to perform this function, we just want to return a normal invalid message. - throw new HttpResponseException(HttpStatusCode.BadRequest); + return SetPrincipalAndReturnUserDetail(user, owinContext.Request.User); } + + if (result.RequiresTwoFactor) + { + var twofactorOptions = UserManager as IUmbracoBackOfficeTwoFactorOptions; + if (twofactorOptions == null) + { + throw new HttpResponseException( + Request.CreateErrorResponse( + HttpStatusCode.BadRequest, + "UserManager does not implement " + typeof(IUmbracoBackOfficeTwoFactorOptions))); + } + + var twofactorView = twofactorOptions.GetTwoFactorView( + owinContext, + UmbracoContext, + loginModel.Username); + + if (twofactorView.IsNullOrWhiteSpace()) + { + throw new HttpResponseException( + Request.CreateErrorResponse( + HttpStatusCode.BadRequest, + typeof(IUmbracoBackOfficeTwoFactorOptions) + ".GetTwoFactorView returned an empty string")); + } + + var attemptedUser = Services.UserService.GetByUsername(loginModel.Username); + + // create a with information to display a custom two factor send code view + var verifyResponse = Request.CreateResponse(HttpStatusCode.PaymentRequired, new + { + twoFactorView = twofactorView, + userId = attemptedUser.Id + }); + + UserManager.RaiseLoginRequiresVerificationEvent(attemptedUser.Id); + + return verifyResponse; + } + + // return BadRequest (400), we don't want to return a 401 because that get's intercepted + // by our angular helper because it thinks that we need to re-perform the request once we are + // authorized and we don't want to return a 403 because angular will show a warning message indicating + // that the user doesn't have access to perform this function, we just want to return a normal invalid message. + throw new HttpResponseException(HttpStatusCode.BadRequest); } /// @@ -315,13 +313,13 @@ namespace Umbraco.Web.Editors { throw new HttpResponseException(HttpStatusCode.BadRequest); } - var identityUser = await SignInManager.UserManager.FindByEmailAsync(model.Email); + var identityUser = await UserManager.FindByEmailAsync(model.Email); if (identityUser != null) { var user = Services.UserService.GetByEmail(model.Email); if (user != null) { - var code = await UserManager.GeneratePasswordResetTokenAsync(identityUser.Id); + var code = await UserManager.GeneratePasswordResetTokenAsync(identityUser); var callbackUrl = ConstructCallbackUrl(identityUser.Id, code); var message = Services.TextService.Localize("resetPasswordEmailCopyFormat", @@ -329,11 +327,12 @@ namespace Umbraco.Web.Editors UmbracoUserExtensions.GetUserCulture(identityUser.Culture, Services.TextService, GlobalSettings), new[] { identityUser.UserName, callbackUrl }); - await UserManager.SendEmailAsync(identityUser.Id, + // TODO: SB: SendEmailAsync + /*await UserManager.SendEmailAsync(identityUser.Id, Services.TextService.Localize("login/resetPasswordEmailCopySubject", // Ensure the culture of the found user is used for the email! UmbracoUserExtensions.GetUserCulture(identityUser.Culture, Services.TextService, GlobalSettings)), - message); + message);*/ UserManager.RaiseForgotPasswordRequestedEvent(user.Id); } @@ -355,7 +354,10 @@ namespace Umbraco.Web.Editors Logger.Warn("Get2FAProviders :: No verified user found, returning 404"); throw new HttpResponseException(HttpStatusCode.NotFound); } - var userFactors = await UserManager.GetValidTwoFactorProvidersAsync(userId); + + var user = await UserManager.FindByIdAsync(userId.ToString()); + var userFactors = await UserManager.GetValidTwoFactorProvidersAsync(user); + return userFactors; } @@ -399,18 +401,19 @@ namespace Umbraco.Web.Editors var owinContext = TryGetOwinContext().Result; var user = Services.UserService.GetByUsername(userName); - switch (result) + if (result.Succeeded) { - case SignInStatus.Success: - UserManager.RaiseLoginSuccessEvent(user.Id); - return SetPrincipalAndReturnUserDetail(user, owinContext.Request.User); - case SignInStatus.LockedOut: - UserManager.RaiseAccountLockedEvent(user.Id); - return Request.CreateValidationErrorResponse("User is locked out"); - case SignInStatus.Failure: - default: - return Request.CreateValidationErrorResponse("Invalid code"); + UserManager.RaiseLoginSuccessEvent(user.Id); + return SetPrincipalAndReturnUserDetail(user, owinContext.Request.User); } + + if (result.IsLockedOut) + { + UserManager.RaiseAccountLockedEvent(user.Id); + return Request.CreateValidationErrorResponse("User is locked out"); + } + + return Request.CreateValidationErrorResponse("Invalid code"); } /// @@ -420,22 +423,24 @@ namespace Umbraco.Web.Editors [SetAngularAntiForgeryTokens] public async Task PostSetPassword(SetPasswordModel model) { - var result = await UserManager.ResetPasswordAsync(model.UserId, model.ResetCode, model.Password); + var identityUser = await UserManager.FindByIdAsync(model.UserId.ToString()); + + var result = await UserManager.ResetPasswordAsync(identityUser, model.ResetCode, model.Password); if (result.Succeeded) { - var lockedOut = await UserManager.IsLockedOutAsync(model.UserId); + var lockedOut = await UserManager.IsLockedOutAsync(identityUser); if (lockedOut) { Logger.Info("User {UserId} is currently locked out, unlocking and resetting AccessFailedCount", model.UserId); //// var user = await UserManager.FindByIdAsync(model.UserId); - var unlockResult = await UserManager.SetLockoutEndDateAsync(model.UserId, DateTimeOffset.Now); + var unlockResult = await UserManager.SetLockoutEndDateAsync(identityUser, DateTimeOffset.Now); if (unlockResult.Succeeded == false) { Logger.Warn("Could not unlock for user {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First()); } - var resetAccessFailedCountResult = await UserManager.ResetAccessFailedCountAsync(model.UserId); + var resetAccessFailedCountResult = await UserManager.ResetAccessFailedCountAsync(identityUser); if (resetAccessFailedCountResult.Succeeded == false) { Logger.Warn("Could not reset access failed count {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First()); @@ -446,13 +451,13 @@ namespace Umbraco.Web.Editors // if user was only invited, then they have not been approved // but a successful forgot password flow (e.g. if their token had expired and they did a forgot password instead of request new invite) // means we have verified their email - if (!UserManager.IsEmailConfirmed(model.UserId)) + + // TODO: SB: ConfirmEmailAsync + if (!await UserManager.IsEmailConfirmedAsync(identityUser)) { - await UserManager.ConfirmEmailAsync(model.UserId, model.ResetCode); + await UserManager.ConfirmEmailAsync(identityUser, model.ResetCode); } - // if the user is invited, enable their account on forgot password - var identityUser = await UserManager.FindByIdAsync(model.UserId); // invited is not approved, never logged in, invited date present /* if (LastLoginDate == default && IsApproved == false && InvitedDate != null) @@ -474,7 +479,7 @@ namespace Umbraco.Web.Editors return Request.CreateResponse(HttpStatusCode.OK); } return Request.CreateValidationErrorResponse( - result.Errors.Any() ? result.Errors.First() : "Set password failed"); + result.Errors.Any() ? result.Errors.First().Description : "Set password failed"); } @@ -561,9 +566,8 @@ namespace Umbraco.Web.Editors { foreach (var error in result.Errors) { - ModelState.AddModelError(prefix, error); + ModelState.AddModelError(prefix, error.Description); } } - } } diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index 64cd0d1a0d..85cb9e91c8 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -7,8 +7,6 @@ using System.Threading.Tasks; using System.Web; using System.Web.Mvc; using System.Web.UI; -using Microsoft.AspNet.Identity; -using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin.Security; using Newtonsoft.Json; using Umbraco.Core; @@ -30,6 +28,7 @@ using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Hosting; using Umbraco.Core.IO; using Umbraco.Web.Trees; +using UserLoginInfo = Microsoft.AspNetCore.Identity.UserLoginInfo; namespace Umbraco.Web.Editors { @@ -44,8 +43,8 @@ namespace Umbraco.Web.Editors private readonly IManifestParser _manifestParser; private readonly UmbracoFeatures _features; private readonly IRuntimeState _runtimeState; - private BackOfficeUserManager _userManager; - private BackOfficeSignInManager _signInManager; + private BackOfficeUserManager2 _userManager; + private BackOfficeSignInManager2 _signInManager; private readonly IUmbracoVersion _umbracoVersion; private readonly IGridConfig _gridConfig; private readonly IUmbracoSettingsSection _umbracoSettingsSection; @@ -85,9 +84,9 @@ namespace Umbraco.Web.Editors _httpContextAccessor = httpContextAccessor; } - protected BackOfficeSignInManager SignInManager => _signInManager ?? (_signInManager = OwinContext.GetBackOfficeSignInManager()); + protected BackOfficeSignInManager2 SignInManager => _signInManager ?? (_signInManager = OwinContext.GetBackOfficeSignInManager2()); - protected BackOfficeUserManager UserManager => _userManager ?? (_userManager = OwinContext.GetBackOfficeUserManager()); + protected BackOfficeUserManager2 UserManager => _userManager ?? (_userManager = OwinContext.GetBackOfficeUserManager2()); protected IAuthenticationManager AuthenticationManager => OwinContext.Authentication; @@ -139,21 +138,15 @@ namespace Umbraco.Web.Editors } var id = parts[0]; - int intId; - if (int.TryParse(id, out intId) == false) - { - Logger.Warn("VerifyUser endpoint reached with invalid token: {Invite}", invite); - return RedirectToAction("Default"); - } - var identityUser = await UserManager.FindByIdAsync(intId); + var identityUser = await UserManager.FindByIdAsync(id); if (identityUser == null) { Logger.Warn("VerifyUser endpoint reached with non existing user: {UserId}", id); return RedirectToAction("Default"); } - var result = await UserManager.ConfirmEmailAsync(intId, decoded); + var result = await UserManager.ConfirmEmailAsync(identityUser, decoded); if (result.Succeeded == false) { @@ -329,10 +322,10 @@ namespace Umbraco.Web.Editors [HttpGet] public async Task ValidatePasswordResetCode([Bind(Prefix = "u")]int userId, [Bind(Prefix = "r")]string resetCode) { - var user = UserManager.FindById(userId); + var user = await UserManager.FindByIdAsync(userId.ToString()); if (user != null) { - var result = await UserManager.UserTokenProvider.ValidateAsync("ResetPassword", resetCode, UserManager, user); + var result = await UserManager.VerifyUserTokenAsync(user, "ResetPassword", "ResetPassword", resetCode); // TODO: SB: password reset token provider if (result) { //Add a flag and redirect for it to be displayed @@ -360,7 +353,10 @@ namespace Umbraco.Web.Editors return RedirectToLocal(Url.Action("Default", "BackOffice")); } - var result = await UserManager.AddLoginAsync(User.Identity.GetUserId(), loginInfo.Login); + var user = await UserManager.FindByIdAsync(User.Identity.GetUserId()); + + var result = await UserManager.AddLoginAsync(user, + new UserLoginInfo(loginInfo.Login.LoginProvider, loginInfo.Login.ProviderKey, loginInfo.Login.LoginProvider)); if (result.Succeeded) { return RedirectToLocal(Url.Action("Default", "BackOffice")); @@ -403,7 +399,7 @@ namespace Umbraco.Web.Editors return await ExternalSignInAsync(loginInfo, externalSignInResponse); } - private async Task ExternalSignInAsync(ExternalLoginInfo loginInfo, Func response) + private async Task ExternalSignInAsync(ExternalLoginInfo2 loginInfo, Func response) { if (loginInfo == null) throw new ArgumentNullException("loginInfo"); if (response == null) throw new ArgumentNullException("response"); @@ -424,7 +420,7 @@ namespace Umbraco.Web.Editors } // Sign in the user with this external login provider if the user already has a login - var user = await UserManager.FindAsync(loginInfo.Login); + var user = await UserManager.FindByLoginAsync(loginInfo.Login.LoginProvider, loginInfo.Login.ProviderKey); if (user != null) { // TODO: It might be worth keeping some of the claims associated with the ExternalLoginInfo, in which case we @@ -466,7 +462,7 @@ namespace Umbraco.Web.Editors return response(); } - private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo, ExternalSignInAutoLinkOptions autoLinkOptions) + private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo2 loginInfo, ExternalSignInAutoLinkOptions autoLinkOptions) { if (autoLinkOptions == null) return false; @@ -515,21 +511,22 @@ namespace Umbraco.Web.Editors if (userCreationResult.Succeeded == false) { - ViewData.SetExternalSignInError(userCreationResult.Errors); + ViewData.SetExternalSignInError(userCreationResult.Errors.Select(x => x.Description).ToList()); } else { - var linkResult = await UserManager.AddLoginAsync(autoLinkUser.Id, loginInfo.Login); + var linkResult = await UserManager.AddLoginAsync(autoLinkUser, + new UserLoginInfo(loginInfo.Login.LoginProvider, loginInfo.Login.ProviderKey, loginInfo.Login.LoginProvider)); if (linkResult.Succeeded == false) { - ViewData.SetExternalSignInError(linkResult.Errors); + ViewData.SetExternalSignInError(linkResult.Errors.Select(x => x.Description).ToList()); //If this fails, we should really delete the user since it will be in an inconsistent state! var deleteResult = await UserManager.DeleteAsync(autoLinkUser); if (deleteResult.Succeeded == false) { //DOH! ... this isn't good, combine all errors to be shown - ViewData.SetExternalSignInError(linkResult.Errors.Concat(deleteResult.Errors)); + ViewData.SetExternalSignInError(linkResult.Errors.Concat(deleteResult.Errors).Select(x => x.Description).ToList()); } } else diff --git a/src/Umbraco.Web/Editors/CurrentUserController.cs b/src/Umbraco.Web/Editors/CurrentUserController.cs index 36f39940cb..35ec09eaca 100644 --- a/src/Umbraco.Web/Editors/CurrentUserController.cs +++ b/src/Umbraco.Web/Editors/CurrentUserController.cs @@ -158,7 +158,8 @@ namespace Umbraco.Web.Editors [OverrideAuthorization] public async Task PostSetInvitedUserPassword([FromBody]string newPassword) { - var result = await UserManager.AddPasswordAsync(Security.GetUserId().ResultOr(0), newPassword); + var user = await UserManager.FindByIdAsync(Security.GetUserId().ResultOr(0).ToString()); + var result = await UserManager.AddPasswordAsync(user, newPassword); if (result.Succeeded == false) { diff --git a/src/Umbraco.Web/Editors/PasswordChanger.cs b/src/Umbraco.Web/Editors/PasswordChanger.cs index 179ef8fb45..a6f47efd73 100644 --- a/src/Umbraco.Web/Editors/PasswordChanger.cs +++ b/src/Umbraco.Web/Editors/PasswordChanger.cs @@ -34,7 +34,7 @@ namespace Umbraco.Web.Editors IUser currentUser, IUser savingUser, ChangingPasswordModel passwordModel, - BackOfficeUserManager userMgr) + BackOfficeUserManager2 userMgr) { if (passwordModel == null) throw new ArgumentNullException(nameof(passwordModel)); if (userMgr == null) throw new ArgumentNullException(nameof(userMgr)); @@ -44,6 +44,13 @@ namespace Umbraco.Web.Editors return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Cannot set an empty password", new[] { "value" }) }); } + var backOfficeIdentityUser = await userMgr.FindByIdAsync(savingUser.Id.ToString()); + if (backOfficeIdentityUser == null) + { + //this really shouldn't ever happen... but just in case + return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Password could not be verified", new[] { "oldPassword" }) }); + } + //Are we just changing another user's password? if (passwordModel.OldPassword.IsNullOrWhiteSpace()) { @@ -60,7 +67,7 @@ namespace Umbraco.Web.Editors } //ok, we should be able to reset it - var resetToken = await userMgr.GeneratePasswordResetTokenAsync(savingUser.Id); + var resetToken = await userMgr.GeneratePasswordResetTokenAsync(backOfficeIdentityUser); var resetResult = await userMgr.ChangePasswordWithResetAsync(savingUser.Id, resetToken, passwordModel.NewPassword); @@ -74,15 +81,6 @@ namespace Umbraco.Web.Editors return Attempt.Succeed(new PasswordChangedModel()); } - //we're changing our own password... - - //get the user - var backOfficeIdentityUser = await userMgr.FindByIdAsync(savingUser.Id); - if (backOfficeIdentityUser == null) - { - //this really shouldn't ever happen... but just in case - return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Password could not be verified", new[] { "oldPassword" }) }); - } //is the old password correct? var validateResult = await userMgr.CheckPasswordAsync(backOfficeIdentityUser, passwordModel.OldPassword); if (validateResult == false) @@ -91,7 +89,7 @@ namespace Umbraco.Web.Editors return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Incorrect password", new[] { "oldPassword" }) }); } //can we change to the new password? - var changeResult = await userMgr.ChangePasswordAsync(savingUser.Id, passwordModel.OldPassword, passwordModel.NewPassword); + var changeResult = await userMgr.ChangePasswordAsync(backOfficeIdentityUser, passwordModel.OldPassword, passwordModel.NewPassword); if (changeResult.Succeeded == false) { //no, fail with error messages for "password" diff --git a/src/Umbraco.Web/Editors/UsersController.cs b/src/Umbraco.Web/Editors/UsersController.cs index 3390677a08..d4daf8ae28 100644 --- a/src/Umbraco.Web/Editors/UsersController.cs +++ b/src/Umbraco.Web/Editors/UsersController.cs @@ -10,7 +10,6 @@ using System.Threading.Tasks; using System.Web; using System.Web.Http; using System.Web.Mvc; -using Microsoft.AspNet.Identity; using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Configuration; @@ -20,7 +19,6 @@ using Umbraco.Core.Models; using Umbraco.Core.Models.Editors; using Umbraco.Core.Models.Membership; using Umbraco.Core.Persistence; -using Umbraco.Core.Security; using Umbraco.Core.Services; using Umbraco.Core.Strings; using Umbraco.Web.Editors.Filters; @@ -321,23 +319,18 @@ namespace Umbraco.Web.Editors Request.CreateNotificationValidationErrorResponse(string.Join(", ", created.Errors))); } - //we need to generate a password, however we can only do that if the user manager has a password validator that - //we can read values from - var passwordValidator = UserManager.PasswordValidator as PasswordValidator; - var resetPassword = string.Empty; - if (passwordValidator != null) - { - var password = UserManager.GeneratePassword(); + string resetPassword; + var password = UserManager.GeneratePassword(); - var result = await UserManager.AddPasswordAsync(identityUser.Id, password); - if (result.Succeeded == false) - { - throw new HttpResponseException( - Request.CreateNotificationValidationErrorResponse(string.Join(", ", created.Errors))); - } - resetPassword = password; + var result = await UserManager.AddPasswordAsync(identityUser, password); + if (result.Succeeded == false) + { + throw new HttpResponseException( + Request.CreateNotificationValidationErrorResponse(string.Join(", ", created.Errors))); } + resetPassword = password; + //now re-look the user back up which will now exist var user = Services.UserService.GetByEmail(userSave.Email); @@ -472,7 +465,8 @@ namespace Umbraco.Web.Editors private async Task SendUserInviteEmailAsync(UserBasic userDisplay, string from, string fromEmail, IUser to, string message) { - var token = await UserManager.GenerateEmailConfirmationTokenAsync((int)userDisplay.Id); + var user = await UserManager.FindByIdAsync(((int) userDisplay.Id).ToString()); + var token = await UserManager.GenerateEmailConfirmationTokenAsync(user); var inviteToken = string.Format("{0}{1}{2}", (int)userDisplay.Id, @@ -501,7 +495,8 @@ namespace Umbraco.Web.Editors UmbracoUserExtensions.GetUserCulture(to.Language, Services.TextService, GlobalSettings), new[] { userDisplay.Name, from, message, inviteUri.ToString(), fromEmail }); - await UserManager.EmailService.SendAsync( + // TODO: SB: EmailService.SendAsync + /*await UserManager.EmailService.SendAsync( //send the special UmbracoEmailMessage which configures it's own sender //to allow for events to handle sending the message if no smtp is configured new UmbracoEmailMessage(new EmailSender(GlobalSettings, true)) @@ -509,7 +504,7 @@ namespace Umbraco.Web.Editors Body = emailBody, Destination = userDisplay.Email, Subject = emailSubject - }); + });*/ } @@ -705,22 +700,26 @@ namespace Umbraco.Web.Editors if (userIds.Length <= 0) return Request.CreateResponse(HttpStatusCode.OK); + var user = await UserManager.FindByIdAsync(userIds[0].ToString()); + if (userIds.Length == 1) { - var unlockResult = await UserManager.SetLockoutEndDateAsync(userIds[0], DateTimeOffset.Now); + + + var unlockResult = await UserManager.SetLockoutEndDateAsync(user, DateTimeOffset.Now); if (unlockResult.Succeeded == false) { return Request.CreateValidationErrorResponse( string.Format("Could not unlock for user {0} - error {1}", userIds[0], unlockResult.Errors.First())); } - var user = await UserManager.FindByIdAsync(userIds[0]); + return Request.CreateNotificationSuccessResponse( Services.TextService.Localize("speechBubbles/unlockUserSuccess", new[] { user.Name })); } foreach (var u in userIds) { - var unlockResult = await UserManager.SetLockoutEndDateAsync(u, DateTimeOffset.Now); + var unlockResult = await UserManager.SetLockoutEndDateAsync(user, DateTimeOffset.Now); if (unlockResult.Succeeded == false) { return Request.CreateValidationErrorResponse( diff --git a/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs b/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs index e47a8f4b5f..6cc77a369f 100644 --- a/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs +++ b/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs @@ -56,15 +56,15 @@ namespace Umbraco.Web.Install.InstallSteps throw new InvalidOperationException("Could not find the super user!"); } - var userManager = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().GetBackOfficeUserManager(); - var membershipUser = await userManager.FindByIdAsync(Constants.Security.SuperUserId); + var userManager = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().GetBackOfficeUserManager2(); + var membershipUser = await userManager.FindByIdAsync(Constants.Security.SuperUserId.ToString()); if (membershipUser == null) { throw new InvalidOperationException($"No user found in membership provider with id of {Constants.Security.SuperUserId}."); } //To change the password here we actually need to reset it since we don't have an old one to use to change - var resetToken = await userManager.GeneratePasswordResetTokenAsync(membershipUser.Id); + var resetToken = await userManager.GeneratePasswordResetTokenAsync(membershipUser); var resetResult = await userManager.ChangePasswordWithResetAsync(membershipUser.Id, resetToken, user.Password.Trim()); if (!resetResult.Succeeded) { diff --git a/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs b/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs index 0baac36032..65d260b9c5 100644 --- a/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs +++ b/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs @@ -1,32 +1,7 @@ -using Microsoft.AspNet.Identity; -using Umbraco.Core.Models.Identity; +using Umbraco.Core.Models.Identity; namespace Umbraco.Web.Models.Identity { - internal class UserLoginInfoWrapper : IUserLoginInfo - { - private readonly UserLoginInfo _info; - - public static IUserLoginInfo Wrap(UserLoginInfo info) => new UserLoginInfoWrapper(info); - - private UserLoginInfoWrapper(UserLoginInfo info) - { - _info = info; - } - - public string LoginProvider - { - get => _info.LoginProvider; - set => _info.LoginProvider = value; - } - - public string ProviderKey - { - get => _info.ProviderKey; - set => _info.ProviderKey = value; - } - } - internal class UserLoginInfoWrapper2 : IUserLoginInfo { private readonly Microsoft.AspNetCore.Identity.UserLoginInfo _info; diff --git a/src/Umbraco.Web/OwinExtensions.cs b/src/Umbraco.Web/OwinExtensions.cs index 25edda2647..66e7e5353e 100644 --- a/src/Umbraco.Web/OwinExtensions.cs +++ b/src/Umbraco.Web/OwinExtensions.cs @@ -57,10 +57,10 @@ namespace Umbraco.Web /// /// /// - public static BackOfficeSignInManager GetBackOfficeSignInManager(this IOwinContext owinContext) + public static BackOfficeSignInManager2 GetBackOfficeSignInManager(this IOwinContext owinContext) { - return owinContext.Get() - ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager)} from the {typeof(IOwinContext)}."); + return owinContext.Get() + ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager2)} from the {typeof(IOwinContext)}."); } /// @@ -83,13 +83,13 @@ namespace Umbraco.Web /// This is required because to extract the user manager we need to user a custom service since owin only deals in generics and /// developers could register their own user manager types /// - public static BackOfficeUserManager GetBackOfficeUserManager(this IOwinContext owinContext) + public static BackOfficeUserManager2 GetBackOfficeUserManager(this IOwinContext owinContext) { - var marker = owinContext.Get(BackOfficeUserManager.OwinMarkerKey) + var marker = owinContext.Get(BackOfficeUserManager2.OwinMarkerKey) ?? throw new NullReferenceException($"No {typeof (IBackOfficeUserManagerMarker)}, i.e. no Umbraco back-office, has been registered with Owin."); return marker.GetManager(owinContext) - ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager)} from the {typeof (IOwinContext)}."); + ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager2)} from the {typeof (IOwinContext)}."); } /// @@ -107,7 +107,7 @@ namespace Umbraco.Web ?? throw new NullReferenceException($"No {typeof (IBackOfficeUserManagerMarker2)}, i.e. no Umbraco back-office, has been registered with Owin."); return marker.GetManager(owinContext) - ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager)} from the {typeof (IOwinContext)}."); + ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager2)} from the {typeof (IOwinContext)}."); } // TODO: SB: OWIN DI diff --git a/src/Umbraco.Web/Security/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/AppBuilderExtensions.cs index 041b56f1c8..e7f2d0272a 100644 --- a/src/Umbraco.Web/Security/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/AppBuilderExtensions.cs @@ -1,8 +1,8 @@ using System; using System.Threading; -using Microsoft.AspNet.Identity; -using Microsoft.AspNet.Identity.Owin; -using Microsoft.Owin; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Microsoft.Owin.Extensions; using Microsoft.Owin.Logging; using Microsoft.Owin.Security; @@ -16,7 +16,6 @@ using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.IO; using Umbraco.Core.Mapping; -using Umbraco.Core.Models.Identity; using Umbraco.Core.Security; using Umbraco.Core.Services; using Umbraco.Net; @@ -34,16 +33,10 @@ namespace Umbraco.Web.Security /// /// Configure Default Identity User Manager for Umbraco /// - /// - /// - /// - /// - /// public static void ConfigureUserManagerForUmbracoBackOffice(this IAppBuilder app, ServiceContext services, - UmbracoMapper mapper, - IContentSection contentSettings, IGlobalSettings globalSettings, + UmbracoMapper mapper, // TODO: This could probably be optional? IPasswordConfiguration passwordConfiguration, IIpResolver ipResolver) @@ -51,39 +44,37 @@ namespace Umbraco.Web.Security if (services == null) throw new ArgumentNullException(nameof(services)); //Configure Umbraco user manager to be created per request - app.CreatePerOwinContext( - (options, owinContext) => BackOfficeUserManager.Create( - options, + app.CreatePerOwinContext( + (options, owinContext) => BackOfficeUserManager2.Create( services.UserService, services.EntityService, services.ExternalLoginService, - mapper, - contentSettings, globalSettings, + mapper, passwordConfiguration, - ipResolver)); + ipResolver, + new OptionsWrapper(new IdentityOptions()), + new UserAwarePasswordHasher2(new PasswordSecurity(passwordConfiguration)), + new[] {new UserValidator(),}, + new[] {new PasswordValidator()}, + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + null, + new NullLogger>())); - app.SetBackOfficeUserManagerType(); + app.SetBackOfficeUserManagerType(); //Create a sign in manager per request - app.CreatePerOwinContext((options, context) => BackOfficeSignInManager.Create(options, context, globalSettings, app.CreateLogger())); + app.CreatePerOwinContext((options, context) => BackOfficeSignInManager2.Create(context, globalSettings, app.CreateLogger())); } /// /// Configure a custom UserStore with the Identity User Manager for Umbraco /// - /// - /// - /// - /// - /// - /// - /// public static void ConfigureUserManagerForUmbracoBackOffice(this IAppBuilder app, IRuntimeState runtimeState, - IContentSection contentSettings, IGlobalSettings globalSettings, - BackOfficeUserStore customUserStore, + BackOfficeUserStore2 customUserStore, // TODO: This could probably be optional? IPasswordConfiguration passwordConfiguration, IIpResolver ipResolver) @@ -92,22 +83,28 @@ namespace Umbraco.Web.Security if (customUserStore == null) throw new ArgumentNullException(nameof(customUserStore)); //Configure Umbraco user manager to be created per request - app.CreatePerOwinContext( - (options, owinContext) => BackOfficeUserManager.Create( - options, - customUserStore, - contentSettings, + app.CreatePerOwinContext( + (options, owinContext) => BackOfficeUserManager2.Create( passwordConfiguration, ipResolver, - globalSettings)); + customUserStore, + new OptionsWrapper(new IdentityOptions()), + new UserAwarePasswordHasher2(new PasswordSecurity(passwordConfiguration)), + new[] { new Microsoft.AspNetCore.Identity.UserValidator(), }, + new[] { new PasswordValidator() }, + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + null, + new NullLogger>())); - app.SetBackOfficeUserManagerType(); + app.SetBackOfficeUserManagerType(); //Create a sign in manager per request - app.CreatePerOwinContext((options, context) => BackOfficeSignInManager.Create(options, context, globalSettings, app.CreateLogger(typeof(BackOfficeSignInManager).FullName))); + app.CreatePerOwinContext((options, context) => BackOfficeSignInManager2.Create(context, globalSettings, app.CreateLogger(typeof(BackOfficeSignInManager2).FullName))); } - /// + // TODO: SB: ConfigureUserManagerForUmbracoBackOffice using IdentityFactoryOptions + /*/// /// Configure a custom BackOfficeUserManager for Umbraco /// /// @@ -118,7 +115,7 @@ namespace Umbraco.Web.Security IRuntimeState runtimeState, IGlobalSettings globalSettings, Func, IOwinContext, TManager> userManager) - where TManager : BackOfficeUserManager + where TManager : BackOfficeUserManager2 where TUser : BackOfficeIdentityUser { if (runtimeState == null) throw new ArgumentNullException(nameof(runtimeState)); @@ -130,9 +127,9 @@ namespace Umbraco.Web.Security app.SetBackOfficeUserManagerType(); //Create a sign in manager per request - app.CreatePerOwinContext( - (options, context) => BackOfficeSignInManager.Create(options, context, globalSettings, app.CreateLogger(typeof(BackOfficeSignInManager).FullName))); - } + app.CreatePerOwinContext( + (options, context) => BackOfficeSignInManager2.Create(context, globalSettings, app.CreateLogger(typeof(BackOfficeSignInManager2).FullName))); + }*/ /// /// Ensures that the UmbracoBackOfficeAuthenticationMiddleware is assigned to the pipeline @@ -190,14 +187,15 @@ namespace Umbraco.Web.Security authOptions.Provider = new BackOfficeCookieAuthenticationProvider(userService, runtimeState, globalSettings, ioHelper, umbracoSettingsSection) { + // TODO: SB: SecurityStampValidator // Enables the application to validate the security stamp when the user // logs in. This is a security feature which is used when you // change a password or add an external login to your account. - OnValidateIdentity = SecurityStampValidator - .OnValidateIdentity( + /*OnValidateIdentity = SecurityStampValidator + .OnValidateIdentity( TimeSpan.FromMinutes(30), (manager, user) => manager.GenerateUserIdentityAsync(user), - identity => identity.GetUserId()), + identity => identity.GetUserId()),*/ }; @@ -268,7 +266,7 @@ namespace Umbraco.Web.Security /// differently in the owin context /// private static void SetBackOfficeUserManagerType(this IAppBuilder app) - where TManager : BackOfficeUserManager + where TManager : BackOfficeUserManager2 where TUser : BackOfficeIdentityUser { if (_markerSet) throw new InvalidOperationException("The back office user manager marker has already been set, only one back office user manager can be configured"); @@ -278,7 +276,7 @@ namespace Umbraco.Web.Security // a generic strongly typed instance app.Use((context, func) => { - context.Set(BackOfficeUserManager.OwinMarkerKey, new BackOfficeUserManagerMarker()); + context.Set(BackOfficeUserManager2.OwinMarkerKey, new BackOfficeUserManagerMarker2()); return func(); }); } diff --git a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs index 5da9c77d6b..d648d9ce78 100644 --- a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs @@ -1,15 +1,14 @@ using System; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.AspNet.Identity; -using Microsoft.AspNet.Identity.Owin; +using Microsoft.AspNetCore.Identity; using Microsoft.Owin.Security; namespace Umbraco.Web.Security { public static class AuthenticationManagerExtensions { - private static ExternalLoginInfo GetExternalLoginInfo(AuthenticateResult result) + private static ExternalLoginInfo2 GetExternalLoginInfo(AuthenticateResult result) { if (result == null || result.Identity == null) { @@ -26,11 +25,12 @@ namespace Umbraco.Web.Security { name = name.Replace(" ", ""); } - var email = result.Identity.FindFirstValue(ClaimTypes.Email); - return new ExternalLoginInfo + + var email = result.Identity.FindFirst(ClaimTypes.Email)?.Value; + return new ExternalLoginInfo2 { ExternalIdentity = result.Identity, - Login = new UserLoginInfo(idClaim.Issuer, idClaim.Value), + Login = new UserLoginInfo(idClaim.Issuer, idClaim.Value, idClaim.Issuer), DefaultUserName = name, Email = email }; @@ -47,7 +47,7 @@ namespace Umbraco.Web.Security /// dictionary /// /// - public static async Task GetExternalLoginInfoAsync(this IAuthenticationManager manager, + public static async Task GetExternalLoginInfoAsync(this IAuthenticationManager manager, string authenticationType, string xsrfKey, string expectedValue) { @@ -74,7 +74,7 @@ namespace Umbraco.Web.Security /// /// /// - public static async Task GetExternalLoginInfoAsync(this IAuthenticationManager manager, string authenticationType) + public static async Task GetExternalLoginInfoAsync(this IAuthenticationManager manager, string authenticationType) { if (manager == null) { @@ -83,4 +83,19 @@ namespace Umbraco.Web.Security return GetExternalLoginInfo(await manager.AuthenticateAsync(authenticationType)); } } + + public class ExternalLoginInfo2 + { + /// Associated login data + public UserLoginInfo Login { get; set; } + + /// Suggested user name for a user + public string DefaultUserName { get; set; } + + /// Email claim from the external identity + public string Email { get; set; } + + /// The external identity + public ClaimsIdentity ExternalIdentity { get; set; } + } } diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs deleted file mode 100644 index fe5b061d15..0000000000 --- a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs +++ /dev/null @@ -1,337 +0,0 @@ -using System; -using System.Diagnostics; -using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.AspNet.Identity; -using Microsoft.AspNet.Identity.Owin; -using Microsoft.Owin; -using Microsoft.Owin.Logging; -using Microsoft.Owin.Security; -using Umbraco.Core; -using Umbraco.Core.Configuration; -using Umbraco.Core.Models.Identity; -using Umbraco.Core.Security; -using Umbraco.Web.Models.Identity; -using Constants = Umbraco.Core.Constants; - -namespace Umbraco.Web.Security -{ - // TODO: In v8 we need to change this to use an int? nullable TKey instead, see notes against overridden TwoFactorSignInAsync - public class BackOfficeSignInManager : SignInManager - { - private readonly ILogger _logger; - private readonly IOwinRequest _request; - private readonly IGlobalSettings _globalSettings; - - public BackOfficeSignInManager(UserManager userManager, IAuthenticationManager authenticationManager, ILogger logger, IGlobalSettings globalSettings, IOwinRequest request) - : base(userManager, authenticationManager) - { - if (logger == null) throw new ArgumentNullException("logger"); - if (request == null) throw new ArgumentNullException("request"); - _logger = logger; - _request = request; - _globalSettings = globalSettings; - AuthenticationType = Constants.Security.BackOfficeAuthenticationType; - } - - public override Task CreateUserIdentityAsync(BackOfficeIdentityUser user) - { - return ((BackOfficeUserManager)UserManager).GenerateUserIdentityAsync(user); - } - - public static BackOfficeSignInManager Create(IdentityFactoryOptions options, IOwinContext context, IGlobalSettings globalSettings, ILogger logger) - { - return new BackOfficeSignInManager( - context.GetBackOfficeUserManager(), - context.Authentication, - logger, - globalSettings, - context.Request); - } - - /// - /// Sign in the user in using the user name and password - /// - /// - /// - public override async Task PasswordSignInAsync(string userName, string password, bool isPersistent, bool shouldLockout) - { - var result = await PasswordSignInAsyncImpl(userName, password, isPersistent, shouldLockout); - - switch (result) - { - case SignInStatus.Success: - _logger.WriteCore(TraceEventType.Information, 0, - $"User: {userName} logged in from IP address {_request.RemoteIpAddress}", null, null); - break; - case SignInStatus.LockedOut: - _logger.WriteCore(TraceEventType.Information, 0, - $"Login attempt failed for username {userName} from IP address {_request.RemoteIpAddress}, the user is locked", null, null); - break; - case SignInStatus.RequiresVerification: - _logger.WriteCore(TraceEventType.Information, 0, - $"Login attempt requires verification for username {userName} from IP address {_request.RemoteIpAddress}", null, null); - break; - case SignInStatus.Failure: - _logger.WriteCore(TraceEventType.Information, 0, - $"Login attempt failed for username {userName} from IP address {_request.RemoteIpAddress}", null, null); - break; - default: - throw new ArgumentOutOfRangeException(); - } - - return result; - } - - /// - /// Borrowed from Microsoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type - /// - /// - /// - /// - /// - /// - private async Task PasswordSignInAsyncImpl(string userName, string password, bool isPersistent, bool shouldLockout) - { - if (UserManager == null) - { - return SignInStatus.Failure; - } - - var user = await UserManager.FindByNameAsync(userName); - - //if the user is null, create an empty one which can be used for auto-linking - if (user == null) - user = BackOfficeIdentityUser.CreateNew(_globalSettings, userName, null, _globalSettings.DefaultUILanguage); - - //check the password for the user, this will allow a developer to auto-link - //an account if they have specified an IBackOfficeUserPasswordChecker - if (await UserManager.CheckPasswordAsync(user, password)) - { - //the underlying call to this will query the user by Id which IS cached! - if (await UserManager.IsLockedOutAsync(user.Id)) - { - return SignInStatus.LockedOut; - } - - // We need to verify that the user belongs to one or more groups that define content and media start nodes. - // To do so we have to create the user claims identity and validate the calculated start nodes. - var userIdentity = await CreateUserIdentityAsync(user); - if (userIdentity is UmbracoBackOfficeIdentity backOfficeIdentity) - { - if (backOfficeIdentity.StartContentNodes.Length == 0 || backOfficeIdentity.StartMediaNodes.Length == 0) - { - _logger.WriteCore(TraceEventType.Information, 0, - $"Login attempt failed for username {userName} from IP address {_request.RemoteIpAddress}, no content and/or media start nodes could be found for any of the user's groups", null, null); - return SignInStatus.Failure; - } - } - - await UserManager.ResetAccessFailedCountAsync(user.Id); - return await SignInOrTwoFactor(user, isPersistent); - } - - var requestContext = _request.Context; - - if (user.HasIdentity && shouldLockout) - { - // If lockout is requested, increment access failed count which might lock out the user - await UserManager.AccessFailedAsync(user.Id); - if (await UserManager.IsLockedOutAsync(user.Id)) - { - //at this point we've just locked the user out after too many failed login attempts - - if (requestContext != null) - { - var backofficeUserManager = requestContext.GetBackOfficeUserManager(); - if (backofficeUserManager != null) - backofficeUserManager.RaiseAccountLockedEvent(user.Id); - } - - return SignInStatus.LockedOut; - } - } - - if (requestContext != null) - { - var backofficeUserManager = requestContext.GetBackOfficeUserManager(); - if (backofficeUserManager != null) - backofficeUserManager.RaiseInvalidLoginAttemptEvent(userName); - } - - return SignInStatus.Failure; - } - - /// - /// Borrowed from Microsoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type - /// - /// - /// - /// - private async Task SignInOrTwoFactor(BackOfficeIdentityUser user, bool isPersistent) - { - var id = Convert.ToString(user.Id); - if (await UserManager.GetTwoFactorEnabledAsync(user.Id) - && (await UserManager.GetValidTwoFactorProvidersAsync(user.Id)).Count > 0) - { - var identity = new ClaimsIdentity(Constants.Security.BackOfficeTwoFactorAuthenticationType); - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, id)); - identity.AddClaim(new Claim(ClaimsIdentity.DefaultNameClaimType, user.UserName)); - AuthenticationManager.SignIn(identity); - return SignInStatus.RequiresVerification; - } - await SignInAsync(user, isPersistent, false); - return SignInStatus.Success; - } - - /// - /// Creates a user identity and then signs the identity using the AuthenticationManager - /// - /// - /// - /// - /// - public override async Task SignInAsync(BackOfficeIdentityUser user, bool isPersistent, bool rememberBrowser) - { - var userIdentity = await CreateUserIdentityAsync(user); - - // Clear any partial cookies from external or two factor partial sign ins - AuthenticationManager.SignOut( - Constants.Security.BackOfficeExternalAuthenticationType, - Constants.Security.BackOfficeTwoFactorAuthenticationType); - - var nowUtc = DateTime.Now.ToUniversalTime(); - - if (rememberBrowser) - { - var rememberBrowserIdentity = AuthenticationManager.CreateTwoFactorRememberBrowserIdentity(ConvertIdToString(user.Id)); - AuthenticationManager.SignIn(new AuthenticationProperties() - { - IsPersistent = isPersistent, - AllowRefresh = true, - IssuedUtc = nowUtc, - ExpiresUtc = nowUtc.AddMinutes(_globalSettings.TimeOutInMinutes) - }, userIdentity, rememberBrowserIdentity); - } - else - { - AuthenticationManager.SignIn(new AuthenticationProperties() - { - IsPersistent = isPersistent, - AllowRefresh = true, - IssuedUtc = nowUtc, - ExpiresUtc = nowUtc.AddMinutes(_globalSettings.TimeOutInMinutes) - }, userIdentity); - } - - //track the last login date - user.LastLoginDateUtc = DateTime.UtcNow; - if (user.AccessFailedCount > 0) - //we have successfully logged in, reset the AccessFailedCount - user.AccessFailedCount = 0; - await UserManager.UpdateAsync(user); - - //set the current request's principal to the identity just signed in! - _request.User = new ClaimsPrincipal(userIdentity); - - _logger.WriteCore(TraceEventType.Information, 0, - string.Format( - "Login attempt succeeded for username {0} from IP address {1}", - user.UserName, - _request.RemoteIpAddress), null, null); - } - - /// - /// Get the user id that has been verified already or int.MinValue if the user has not been verified yet - /// - /// - /// - /// Replaces the underlying call which is not flexible and doesn't support a custom cookie - /// - public new async Task GetVerifiedUserIdAsync() - { - var result = await AuthenticationManager.AuthenticateAsync(Constants.Security.BackOfficeTwoFactorAuthenticationType); - if (result != null && result.Identity != null && string.IsNullOrEmpty(result.Identity.GetUserId()) == false) - { - return ConvertIdFromString(result.Identity.GetUserId()); - } - return int.MinValue; - } - - /// - /// Get the username that has been verified already or null. - /// - /// - public async Task GetVerifiedUserNameAsync() - { - var result = await AuthenticationManager.AuthenticateAsync(Constants.Security.BackOfficeTwoFactorAuthenticationType); - if (result != null && result.Identity != null && string.IsNullOrEmpty(result.Identity.GetUserName()) == false) - { - return result.Identity.GetUserName(); - } - return null; - } - - /// - /// Two factor verification step - /// - /// - /// - /// - /// - /// - /// - /// This is implemented because we cannot override GetVerifiedUserIdAsync and instead we have to shadow it - /// so due to this and because we are using an INT as the TKey and not an object, it can never be null. Adding to that - /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate - /// all of this code to check for int.MinValue - /// - public override async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberBrowser) - { - var userId = await GetVerifiedUserIdAsync(); - if (userId == int.MinValue) - { - return SignInStatus.Failure; - } - var user = await UserManager.FindByIdAsync(userId); - if (user == null) - { - return SignInStatus.Failure; - } - if (await UserManager.IsLockedOutAsync(user.Id)) - { - return SignInStatus.LockedOut; - } - if (await UserManager.VerifyTwoFactorTokenAsync(user.Id, provider, code)) - { - // When token is verified correctly, clear the access failed count used for lockout - await UserManager.ResetAccessFailedCountAsync(user.Id); - await SignInAsync(user, isPersistent, rememberBrowser); - return SignInStatus.Success; - } - // If the token is incorrect, record the failure which also may cause the user to be locked out - await UserManager.AccessFailedAsync(user.Id); - return SignInStatus.Failure; - } - - /// Send a two factor code to a user - /// - /// - /// - /// This is implemented because we cannot override GetVerifiedUserIdAsync and instead we have to shadow it - /// so due to this and because we are using an INT as the TKey and not an object, it can never be null. Adding to that - /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate - /// all of this code to check for int.MinVale instead. - /// - public override async Task SendTwoFactorCodeAsync(string provider) - { - var userId = await GetVerifiedUserIdAsync(); - if (userId == int.MinValue) - return false; - - var token = await UserManager.GenerateTwoFactorTokenAsync(userId, provider); - var identityResult = await UserManager.NotifyTwoFactorTokenAsync(userId, provider, token); - return identityResult.Succeeded; - } - } -} diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs index ac33eb208d..37284cc04c 100644 --- a/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs +++ b/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs @@ -18,7 +18,7 @@ namespace Umbraco.Web.Security /// Custom sign in manager due to SignInManager not being .NET Standard. /// Can be removed once the web project moves to .NET Core. /// - public class BackOfficeSignInManager2 + public class BackOfficeSignInManager2 : IDisposable { private readonly BackOfficeUserManager2 _userManager; private readonly IAuthenticationManager _authenticationManager; @@ -350,5 +350,10 @@ namespace Umbraco.Web.Security { return id == null ? default(int) : (int) Convert.ChangeType(id, typeof(int), CultureInfo.InvariantCulture); } + + public void Dispose() + { + _userManager?.Dispose(); + } } } diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager.cs b/src/Umbraco.Web/Security/BackOfficeUserManager.cs deleted file mode 100644 index 6dda5a8b44..0000000000 --- a/src/Umbraco.Web/Security/BackOfficeUserManager.cs +++ /dev/null @@ -1,641 +0,0 @@ -using System; -using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.AspNet.Identity; -using Microsoft.AspNet.Identity.Owin; -using Microsoft.Owin.Security.DataProtection; -using Umbraco.Core; -using Umbraco.Core.Configuration; -using Umbraco.Core.Configuration.UmbracoSettings; -using Umbraco.Core.Mapping; -using Umbraco.Core.Models.Identity; -using Umbraco.Core.Security; -using Umbraco.Core.Services; -using Umbraco.Net; -using Umbraco.Web.Models.Identity; -using IPasswordHasher = Microsoft.AspNet.Identity.IPasswordHasher; - -namespace Umbraco.Web.Security -{ - /// - /// Default back office user manager - /// - public class BackOfficeUserManager : BackOfficeUserManager - { - public const string OwinMarkerKey = "Umbraco.Web.Security.Identity.BackOfficeUserManagerMarker"; - - public BackOfficeUserManager( - IUserStore store, - IdentityFactoryOptions options, - IContentSection contentSectionConfig, - IPasswordConfiguration passwordConfiguration, - IIpResolver ipResolver, - IGlobalSettings globalSettings) - : base(store, passwordConfiguration, ipResolver) - { - if (options == null) throw new ArgumentNullException("options"); - InitUserManager(this, passwordConfiguration, options.DataProtectionProvider, contentSectionConfig, globalSettings); - } - - #region Static Create methods - - /// - /// Creates a BackOfficeUserManager instance with all default options and the default BackOfficeUserManager - /// - /// - /// - /// - /// - /// - /// - /// - /// - public static BackOfficeUserManager Create( - IdentityFactoryOptions options, - IUserService userService, - IEntityService entityService, - IExternalLoginService externalLoginService, - UmbracoMapper mapper, - IContentSection contentSectionConfig, - IGlobalSettings globalSettings, - IPasswordConfiguration passwordConfiguration, - IIpResolver ipResolver) - { - if (options == null) throw new ArgumentNullException("options"); - if (userService == null) throw new ArgumentNullException("userService"); - if (externalLoginService == null) throw new ArgumentNullException("externalLoginService"); - - var store = new BackOfficeUserStore(userService, entityService, externalLoginService, globalSettings, mapper); - var manager = new BackOfficeUserManager(store, options, contentSectionConfig, passwordConfiguration, ipResolver, globalSettings); - return manager; - } - - /// - /// Creates a BackOfficeUserManager instance with all default options and a custom BackOfficeUserManager instance - /// - /// - /// - /// - /// - /// - public static BackOfficeUserManager Create( - IdentityFactoryOptions options, - BackOfficeUserStore customUserStore, - IContentSection contentSectionConfig, - IPasswordConfiguration passwordConfiguration, - IIpResolver ipResolver, - IGlobalSettings globalSettings) - { - var manager = new BackOfficeUserManager(customUserStore, options, contentSectionConfig, passwordConfiguration, ipResolver, globalSettings); - return manager; - } - #endregion - - - } - - /// - /// Generic Back office user manager - /// - public class BackOfficeUserManager : UserManager - where T : BackOfficeIdentityUser - { - private PasswordGenerator _passwordGenerator; - - public BackOfficeUserManager(IUserStore store, - IPasswordConfiguration passwordConfiguration, - IIpResolver ipResolver) - : base(store) - { - PasswordConfiguration = passwordConfiguration; - IpResolver = ipResolver; - } - - #region What we support do not currently - - // TODO: We could support this - but a user claims will mostly just be what is in the auth cookie - public override bool SupportsUserClaim - { - get { return false; } - } - - // TODO: Support this - public override bool SupportsQueryableUsers - { - get { return false; } - } - - /// - /// Developers will need to override this to support custom 2 factor auth - /// - public override bool SupportsUserTwoFactor - { - get { return false; } - } - - // TODO: Support this - public override bool SupportsUserPhoneNumber - { - get { return false; } - } - #endregion - - public virtual async Task GenerateUserIdentityAsync(T user) - { - // NOTE the authenticationType must match the umbraco one - // defined in CookieAuthenticationOptions.AuthenticationType - var userIdentity = await CreateIdentityAsync(user, Core.Constants.Security.BackOfficeAuthenticationType); - return userIdentity; - } - - /// - /// Initializes the user manager with the correct options - /// - /// - /// - /// - /// - /// - protected void InitUserManager( - BackOfficeUserManager manager, - IPasswordConfiguration passwordConfig, - IDataProtectionProvider dataProtectionProvider, - IContentSection contentSectionConfig, - IGlobalSettings globalSettings) - { - // Configure validation logic for usernames - manager.UserValidator = new BackOfficeUserValidator(manager) - { - AllowOnlyAlphanumericUserNames = false, - RequireUniqueEmail = true - }; - - // Configure validation logic for passwords - manager.PasswordValidator = new ConfiguredPasswordValidator(passwordConfig); - - //use a custom hasher based on our membership provider - manager.PasswordHasher = GetDefaultPasswordHasher(passwordConfig); - - if (dataProtectionProvider != null) - { - manager.UserTokenProvider = new DataProtectorTokenProvider(dataProtectionProvider.Create("ASP.NET Identity")) - { - TokenLifespan = TimeSpan.FromDays(3) - }; - } - - manager.UserLockoutEnabledByDefault = true; - manager.MaxFailedAccessAttemptsBeforeLockout = passwordConfig.MaxFailedAccessAttemptsBeforeLockout; - //NOTE: This just needs to be in the future, we currently don't support a lockout timespan, it's either they are locked - // or they are not locked, but this determines what is set on the account lockout date which corresponds to whether they are - // locked out or not. - manager.DefaultAccountLockoutTimeSpan = TimeSpan.FromDays(30); - - //custom identity factory for creating the identity object for which we auth against in the back office - manager.ClaimsIdentityFactory = new BackOfficeClaimsIdentityFactory(); - - manager.EmailService = new EmailService( - contentSectionConfig.NotificationEmailAddress, - new EmailSender(globalSettings)); - - //NOTE: Not implementing these, if people need custom 2 factor auth, they'll need to implement their own UserStore to support 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.SmsService = new SmsService(); - } - - /// - /// Used to validate a user's session - /// - /// - /// - /// - public virtual async Task ValidateSessionIdAsync(int userId, string sessionId) - { - var userSessionStore = Store as IUserSessionStore; - //if this is not set, for backwards compat (which would be super rare), we'll just approve it - if (userSessionStore == null) - return true; - - return await userSessionStore.ValidateSessionIdAsync(userId, sessionId); - } - - /// - /// This will determine which password hasher to use based on what is defined in config - /// - /// - protected virtual IPasswordHasher GetDefaultPasswordHasher(IPasswordConfiguration passwordConfiguration) - { - //we can use the user aware password hasher (which will be the default and preferred way) - return new UserAwarePasswordHasher(new PasswordSecurity(passwordConfiguration)); - } - - /// - /// Gets/sets the default back office user password checker - /// - public IBackOfficeUserPasswordChecker BackOfficeUserPasswordChecker { get; set; } - public IPasswordConfiguration PasswordConfiguration { get; } - public IIpResolver IpResolver { get; } - - /// - /// Helper method to generate a password for a user based on the current password validator - /// - /// - public string GeneratePassword() - { - if (_passwordGenerator == null) _passwordGenerator = new PasswordGenerator(PasswordConfiguration); - var password = _passwordGenerator.GeneratePassword(); - return password; - } - - /// - /// Override to check the user approval value as well as the user lock out date, by default this only checks the user's locked out date - /// - /// - /// - /// - /// In the ASP.NET Identity world, there is only one value for being locked out, in Umbraco we have 2 so when checking this for Umbraco we need to check both values - /// - public override async Task IsLockedOutAsync(int userId) - { - var user = await FindByIdAsync(userId); - if (user == null) - throw new InvalidOperationException("No user found by id " + userId); - if (user.IsApproved == false) - return true; - - return await base.IsLockedOutAsync(userId); - } - - #region Overrides for password logic - - /// - /// Logic used to validate a username and password - /// - /// - /// - /// - /// - /// By default this uses the standard ASP.Net Identity approach which is: - /// * Get password store - /// * Call VerifyPasswordAsync with the password store + user + password - /// * Uses the PasswordHasher.VerifyHashedPassword to compare the stored password - /// - /// In some cases people want simple custom control over the username/password check, for simplicity - /// sake, developers would like the users to simply validate against an LDAP directory but the user - /// data remains stored inside of Umbraco. - /// See: http://issues.umbraco.org/issue/U4-7032 for the use cases. - /// - /// We've allowed this check to be overridden with a simple callback so that developers don't actually - /// have to implement/override this class. - /// - public override async Task CheckPasswordAsync(T user, string password) - { - if (BackOfficeUserPasswordChecker != null) - { - var result = await BackOfficeUserPasswordChecker.CheckPasswordAsync(user, password); - - if (user.HasIdentity == false) - { - return false; - } - - //if the result indicates to not fallback to the default, then return true if the credentials are valid - if (result != BackOfficeUserPasswordCheckerResult.FallbackToDefaultChecker) - { - return result == BackOfficeUserPasswordCheckerResult.ValidCredentials; - } - } - - //we cannot proceed if the user passed in does not have an identity - if (user.HasIdentity == false) - return false; - - //use the default behavior - return await base.CheckPasswordAsync(user, password); - } - - public override Task ResetPasswordAsync(int userId, string token, string newPassword) - { - var result = base.ResetPasswordAsync(userId, token, newPassword); - return result; - } - - /// - /// This is a special method that will reset the password but will raise the Password Changed event instead of the reset event - /// - /// - /// - /// - /// - /// - /// We use this because in the back office the only way an admin can change another user's password without first knowing their password - /// is to generate a token and reset it, however, when we do this we want to track a password change, not a password reset - /// - public Task ChangePasswordWithResetAsync(int userId, string token, string newPassword) - { - var result = base.ResetPasswordAsync(userId, token, newPassword); - if (result.Result.Succeeded) - RaisePasswordChangedEvent(userId); - return result; - } - - public override Task ChangePasswordAsync(int userId, string currentPassword, string newPassword) - { - var result = base.ChangePasswordAsync(userId, currentPassword, newPassword); - if (result.Result.Succeeded) - RaisePasswordChangedEvent(userId); - return result; - } - - /// - /// Override to determine how to hash the password - /// - /// - /// - /// - /// - protected override async Task VerifyPasswordAsync(IUserPasswordStore store, T user, string password) - { - var userAwarePasswordHasher = PasswordHasher as IUserAwarePasswordHasher; - if (userAwarePasswordHasher == null) - return await base.VerifyPasswordAsync(store, user, password); - - var hash = await store.GetPasswordHashAsync(user); - return userAwarePasswordHasher.VerifyHashedPassword(user, hash, password) != PasswordVerificationResult.Failed; - } - - /// - /// Override to determine how to hash the password - /// - /// - /// - /// - /// - /// - /// This method is called anytime the password needs to be hashed for storage (i.e. including when reset password is used) - /// - protected override async Task UpdatePassword(IUserPasswordStore passwordStore, T user, string newPassword) - { - user.LastPasswordChangeDateUtc = DateTime.UtcNow; - var userAwarePasswordHasher = PasswordHasher as IUserAwarePasswordHasher; - if (userAwarePasswordHasher == null) - return await base.UpdatePassword(passwordStore, user, newPassword); - - var result = await PasswordValidator.ValidateAsync(newPassword); - if (result.Succeeded == false) - return result; - - await passwordStore.SetPasswordHashAsync(user, userAwarePasswordHasher.HashPassword(user, newPassword)); - await UpdateSecurityStampInternal(user); - return IdentityResult.Success; - - - } - - /// - /// This is copied from the underlying .NET base class since they decided to not expose it - /// - /// - /// - private async Task UpdateSecurityStampInternal(BackOfficeIdentityUser user) - { - if (SupportsUserSecurityStamp == false) - return; - await GetSecurityStore().SetSecurityStampAsync(user, NewSecurityStamp()); - } - - /// - /// This is copied from the underlying .NET base class since they decided to not expose it - /// - /// - private IUserSecurityStampStore GetSecurityStore() - { - var store = Store as IUserSecurityStampStore; - if (store == null) - throw new NotSupportedException("The current user store does not implement " + typeof(IUserSecurityStampStore<>)); - return store; - } - - /// - /// This is copied from the underlying .NET base class since they decided to not expose it - /// - /// - private static string NewSecurityStamp() - { - return Guid.NewGuid().ToString(); - } - - #endregion - - public override async Task SetLockoutEndDateAsync(int userId, DateTimeOffset lockoutEnd) - { - var result = await base.SetLockoutEndDateAsync(userId, lockoutEnd); - - // The way we unlock is by setting the lockoutEnd date to the current datetime - if (result.Succeeded && lockoutEnd >= DateTimeOffset.UtcNow) - { - RaiseAccountLockedEvent(userId); - } - else - { - RaiseAccountUnlockedEvent(userId); - //Resets the login attempt fails back to 0 when unlock is clicked - await ResetAccessFailedCountAsync(userId); - - } - - return result; - } - - public override async Task ResetAccessFailedCountAsync(int userId) - { - var lockoutStore = (IUserLockoutStore)Store; - var user = await FindByIdAsync(userId); - if (user == null) - throw new InvalidOperationException("No user found by user id " + userId); - - var accessFailedCount = await GetAccessFailedCountAsync(user.Id); - - if (accessFailedCount == 0) - return IdentityResult.Success; - - await lockoutStore.ResetAccessFailedCountAsync(user); - //raise the event now that it's reset - RaiseResetAccessFailedCountEvent(userId); - return await UpdateAsync(user); - } - - - - /// - /// Overrides the Microsoft ASP.NET user management method - /// - /// - /// - /// returns a Async Task - /// - /// - /// Doesn't set fail attempts back to 0 - /// - public override async Task AccessFailedAsync(int userId) - { - var lockoutStore = (IUserLockoutStore)Store; - var user = await FindByIdAsync(userId); - if (user == null) - throw new InvalidOperationException("No user found by user id " + userId); - - var count = await lockoutStore.IncrementAccessFailedCountAsync(user); - - if (count >= MaxFailedAccessAttemptsBeforeLockout) - { - await lockoutStore.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.Add(DefaultAccountLockoutTimeSpan)); - //NOTE: in normal aspnet identity this would do set the number of failed attempts back to 0 - //here we are persisting the value for the back office - } - - var result = await UpdateAsync(user); - - //Slightly confusing: this will return a Success if we successfully update the AccessFailed count - if (result.Succeeded) - RaiseLoginFailedEvent(userId); - - return result; - } - - internal void RaiseAccountLockedEvent(int userId) - { - OnAccountLocked(new IdentityAuditEventArgs(AuditEvent.AccountLocked, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); - } - - internal void RaiseAccountUnlockedEvent(int userId) - { - OnAccountUnlocked(new IdentityAuditEventArgs(AuditEvent.AccountUnlocked, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); - } - - internal void RaiseForgotPasswordRequestedEvent(int userId) - { - OnForgotPasswordRequested(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordRequested, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); - } - - internal void RaiseForgotPasswordChangedSuccessEvent(int userId) - { - OnForgotPasswordChangedSuccess(new IdentityAuditEventArgs(AuditEvent.ForgotPasswordChangedSuccess, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); - } - - internal void RaiseLoginFailedEvent(int userId) - { - OnLoginFailed(new IdentityAuditEventArgs(AuditEvent.LoginFailed, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); - } - - internal void RaiseInvalidLoginAttemptEvent(string username) - { - OnLoginFailed(new IdentityAuditEventArgs(AuditEvent.LoginFailed, IpResolver.GetCurrentRequestIpAddress(), username, string.Format("Attempted login for username '{0}' failed", username))); - } - - internal void RaiseLoginRequiresVerificationEvent(int userId) - { - OnLoginRequiresVerification(new IdentityAuditEventArgs(AuditEvent.LoginRequiresVerification, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); - } - - internal void RaiseLoginSuccessEvent(int userId) - { - OnLoginSuccess(new IdentityAuditEventArgs(AuditEvent.LoginSucces, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); - } - - internal void RaiseLogoutSuccessEvent(int userId) - { - OnLogoutSuccess(new IdentityAuditEventArgs(AuditEvent.LogoutSuccess, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); - } - - internal void RaisePasswordChangedEvent(int userId) - { - OnPasswordChanged(new IdentityAuditEventArgs(AuditEvent.PasswordChanged, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); - } - - internal void RaiseResetAccessFailedCountEvent(int userId) - { - OnResetAccessFailedCount(new IdentityAuditEventArgs(AuditEvent.ResetAccessFailedCount, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId)); - } - - public static event EventHandler AccountLocked; - public static event EventHandler AccountUnlocked; - public static event EventHandler ForgotPasswordRequested; - public static event EventHandler ForgotPasswordChangedSuccess; - public static event EventHandler LoginFailed; - public static event EventHandler LoginRequiresVerification; - public static event EventHandler LoginSuccess; - public static event EventHandler LogoutSuccess; - public static event EventHandler PasswordChanged; - public static event EventHandler PasswordReset; - public static event EventHandler ResetAccessFailedCount; - - protected virtual void OnAccountLocked(IdentityAuditEventArgs e) - { - if (AccountLocked != null) AccountLocked(this, e); - } - - protected virtual void OnAccountUnlocked(IdentityAuditEventArgs e) - { - if (AccountUnlocked != null) AccountUnlocked(this, e); - } - - protected virtual void OnForgotPasswordRequested(IdentityAuditEventArgs e) - { - if (ForgotPasswordRequested != null) ForgotPasswordRequested(this, e); - } - - protected virtual void OnForgotPasswordChangedSuccess(IdentityAuditEventArgs e) - { - if (ForgotPasswordChangedSuccess != null) ForgotPasswordChangedSuccess(this, e); - } - - protected virtual void OnLoginFailed(IdentityAuditEventArgs e) - { - if (LoginFailed != null) LoginFailed(this, e); - } - - protected virtual void OnLoginRequiresVerification(IdentityAuditEventArgs e) - { - if (LoginRequiresVerification != null) LoginRequiresVerification(this, e); - } - - protected virtual void OnLoginSuccess(IdentityAuditEventArgs e) - { - if (LoginSuccess != null) LoginSuccess(this, e); - } - - protected virtual void OnLogoutSuccess(IdentityAuditEventArgs e) - { - if (LogoutSuccess != null) LogoutSuccess(this, e); - } - - protected virtual void OnPasswordChanged(IdentityAuditEventArgs e) - { - if (PasswordChanged != null) PasswordChanged(this, e); - } - - protected virtual void OnPasswordReset(IdentityAuditEventArgs e) - { - if (PasswordReset != null) PasswordReset(this, e); - } - - protected virtual void OnResetAccessFailedCount(IdentityAuditEventArgs e) - { - if (ResetAccessFailedCount != null) ResetAccessFailedCount(this, e); - } - - } - -} diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs index b22f9523c0..0adc92b77e 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs @@ -42,14 +42,6 @@ namespace Umbraco.Web.Security /// /// Creates a BackOfficeUserManager instance with all default options and the default BackOfficeUserManager /// - /// - /// - /// - /// - /// - /// - /// - /// public static BackOfficeUserManager2 Create( IUserService userService, IEntityService entityService, @@ -85,11 +77,10 @@ namespace Umbraco.Web.Security /// /// Creates a BackOfficeUserManager instance with all default options and a custom BackOfficeUserManager instance /// - /// public static BackOfficeUserManager2 Create( IPasswordConfiguration passwordConfiguration, IIpResolver ipResolver, - IUserStore store, + IUserStore customUserStore, IOptions optionsAccessor, IPasswordHasher passwordHasher, IEnumerable> userValidators, @@ -102,7 +93,7 @@ namespace Umbraco.Web.Security return new BackOfficeUserManager2( passwordConfiguration, ipResolver, - store, + customUserStore, optionsAccessor, passwordHasher, userValidators, @@ -207,9 +198,9 @@ namespace Umbraco.Web.Security /// /// /// - public virtual async Task ValidateSessionIdAsync(int userId, string sessionId) + public virtual async Task ValidateSessionIdAsync(string userId, string sessionId) { - var userSessionStore = Store as IUserSessionStore2; + var userSessionStore = Store as IUserSessionStore2; //if this is not set, for backwards compat (which would be super rare), we'll just approve it if (userSessionStore == null) return true; diff --git a/src/Umbraco.Web/Security/BackOfficeUserManagerMarker.cs b/src/Umbraco.Web/Security/BackOfficeUserManagerMarker.cs index 03477db730..d9f430c84f 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManagerMarker.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManagerMarker.cs @@ -1,8 +1,5 @@ using System; -using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin; -using Umbraco.Core.Models.Identity; -using Umbraco.Core.Security; using Umbraco.Web.Models.Identity; namespace Umbraco.Web.Security @@ -14,14 +11,14 @@ namespace Umbraco.Web.Security /// /// /// - internal class BackOfficeUserManagerMarker : IBackOfficeUserManagerMarker - where TManager : BackOfficeUserManager + internal class BackOfficeUserManagerMarker2 : IBackOfficeUserManagerMarker2 + where TManager : BackOfficeUserManager2 where TUser : BackOfficeIdentityUser { - public BackOfficeUserManager GetManager(IOwinContext owin) + public BackOfficeUserManager2 GetManager(IOwinContext owin) { - var mgr = owin.Get() as BackOfficeUserManager; - if (mgr == null) throw new InvalidOperationException("Could not cast the registered back office user of type " + typeof(TManager) + " to " + typeof(BackOfficeUserManager)); + var mgr = owin.Get() as BackOfficeUserManager2; + if (mgr == null) throw new InvalidOperationException("Could not cast the registered back office user of type " + typeof(TManager) + " to " + typeof(BackOfficeUserManager2)); return mgr; } } diff --git a/src/Umbraco.Web/Security/BackOfficeUserStore.cs b/src/Umbraco.Web/Security/BackOfficeUserStore.cs deleted file mode 100644 index 78eeba5eff..0000000000 --- a/src/Umbraco.Web/Security/BackOfficeUserStore.cs +++ /dev/null @@ -1,776 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNet.Identity; -using Umbraco.Core.Configuration; -using Umbraco.Core.Mapping; -using Umbraco.Core.Models; -using Umbraco.Core.Models.Identity; -using Umbraco.Core.Models.Membership; -using Umbraco.Core.Services; -using Umbraco.Web.Models.Identity; -using IUser = Umbraco.Core.Models.Membership.IUser; -using Task = System.Threading.Tasks.Task; - -namespace Umbraco.Core.Security -{ - public class BackOfficeUserStore : DisposableObjectSlim, - IUserStore, - IUserPasswordStore, - IUserEmailStore, - IUserLoginStore, - IUserRoleStore, - IUserSecurityStampStore, - IUserLockoutStore, - IUserTwoFactorStore, - IUserSessionStore - - // TODO: This would require additional columns/tables for now people will need to implement this on their own - //IUserPhoneNumberStore, - // TODO: To do this we need to implement IQueryable - we'll have an IQuerable implementation soon with the UmbracoLinqPadDriver implementation - //IQueryableUserStore - { - private readonly IUserService _userService; - private readonly IEntityService _entityService; - private readonly IExternalLoginService _externalLoginService; - private readonly IGlobalSettings _globalSettings; - private readonly UmbracoMapper _mapper; - private bool _disposed = false; - - public BackOfficeUserStore(IUserService userService, IEntityService entityService, IExternalLoginService externalLoginService, IGlobalSettings globalSettings, UmbracoMapper mapper) - { - _userService = userService; - _entityService = entityService; - _externalLoginService = externalLoginService; - _globalSettings = globalSettings; - if (userService == null) throw new ArgumentNullException("userService"); - if (externalLoginService == null) throw new ArgumentNullException("externalLoginService"); - _mapper = mapper; - - _userService = userService; - _externalLoginService = externalLoginService; - } - - /// - /// 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(nameof(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. - //this will hash the guid with a salt so should be nicely random - var aspHasher = new PasswordHasher(); - var emptyPasswordValue = Constants.Security.EmptyPasswordPrefix + - aspHasher.HashPassword(Guid.NewGuid().ToString("N")); - - var userEntity = new User(_globalSettings, user.Name, user.Email, user.UserName, emptyPasswordValue) - { - Language = user.Culture ?? _globalSettings.DefaultUILanguage, - StartContentIds = user.StartContentIds ?? new int[] { }, - StartMediaIds = user.StartMediaIds ?? new int[] { }, - IsLockedOut = user.IsLockedOut, - }; - - UpdateMemberProperties(userEntity, user); - - // TODO: We should deal with Roles --> User Groups here which we currently are not doing - - _userService.Save(userEntity); - - if (!userEntity.HasIdentity) throw new DataException("Could not create the user, check logs for details"); - - //re-assign id - user.Id = userEntity.Id; - - return Task.FromResult(0); - } - - /// - /// Update a user - /// - /// - /// - public async Task UpdateAsync(BackOfficeIdentityUser user) - { - ThrowIfDisposed(); - if (user == null) throw new ArgumentNullException(nameof(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) - { - // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. - var isLoginsPropertyDirty = user.IsPropertyDirty("Logins"); - - if (UpdateMemberProperties(found, user)) - { - _userService.Save(found); - } - - if (isLoginsPropertyDirty) - { - var logins = await GetLoginsAsync(user); - _externalLoginService.SaveUserLogins(found.Id, logins.Select(UserLoginInfoWrapper.Wrap)); - } - } - } - - /// - /// Delete a user - /// - /// - /// - public Task DeleteAsync(BackOfficeIdentityUser user) - { - ThrowIfDisposed(); - if (user == null) throw new ArgumentNullException(nameof(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 async Task FindByIdAsync(int userId) - { - ThrowIfDisposed(); - var user = _userService.GetUserById(userId); - if (user == null) - { - return null; - } - - return await Task.FromResult(AssignLoginsCallback(_mapper.Map(user))); - } - - /// - /// Find a user by name - /// - /// - /// - public async Task FindByNameAsync(string userName) - { - ThrowIfDisposed(); - var user = _userService.GetByUsername(userName); - if (user == null) - { - return null; - } - - var result = AssignLoginsCallback(_mapper.Map(user)); - - return await Task.FromResult(result); - } - - /// - /// Set the user password hash - /// - /// - /// - public Task SetPasswordHashAsync(BackOfficeIdentityUser user, string passwordHash) - { - ThrowIfDisposed(); - if (user == null) throw new ArgumentNullException(nameof(user)); - if (passwordHash == null) throw new ArgumentNullException(nameof(passwordHash)); - if (string.IsNullOrEmpty(passwordHash)) throw new ArgumentException("Value can't be empty.", nameof(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(nameof(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(nameof(user)); - - return Task.FromResult(string.IsNullOrEmpty(user.PasswordHash) == false); - } - - /// - /// Set the user email - /// - /// - /// - public Task SetEmailAsync(BackOfficeIdentityUser user, string email) - { - ThrowIfDisposed(); - if (user == null) throw new ArgumentNullException(nameof(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(nameof(user)); - - return Task.FromResult(user.Email); - } - - /// - /// Returns true if the user email is confirmed - /// - /// - /// - public Task GetEmailConfirmedAsync(BackOfficeIdentityUser user) - { - ThrowIfDisposed(); - if (user == null) throw new ArgumentNullException(nameof(user)); - - return Task.FromResult(user.EmailConfirmed); - } - - /// - /// Sets whether the user email is confirmed - /// - /// - /// - public Task SetEmailConfirmedAsync(BackOfficeIdentityUser user, bool confirmed) - { - ThrowIfDisposed(); - user.EmailConfirmed = confirmed; - return Task.FromResult(0); - } - - /// - /// 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(nameof(user)); - if (login == null) throw new ArgumentNullException(nameof(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(nameof(user)); - if (login == null) throw new ArgumentNullException(nameof(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(nameof(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(nameof(login)); - - //get all logins associated with the login id - var result = _externalLoginService.Find(UserLoginInfoWrapper.Wrap(login)).ToArray(); - if (result.Any()) - { - //return the first user that matches the result - BackOfficeIdentityUser output = null; - foreach (var l in result) - { - var user = _userService.GetUserById(l.UserId); - if (user != null) - { - output = _mapper.Map(user); - break; - } - } - - return Task.FromResult(AssignLoginsCallback(output)); - } - - return Task.FromResult(null); - } - - - /// - /// Adds a user to a role (user group) - /// - /// - /// - public Task AddToRoleAsync(BackOfficeIdentityUser user, string roleName) - { - ThrowIfDisposed(); - if (user == null) throw new ArgumentNullException(nameof(user)); - if (roleName == null) throw new ArgumentNullException(nameof(roleName)); - if (string.IsNullOrWhiteSpace(roleName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(roleName)); - - var userRole = user.Roles.SingleOrDefault(r => r.RoleId == roleName); - - if (userRole == null) - { - user.AddRole(roleName); - } - - return Task.FromResult(0); - } - - /// - /// Removes the role (user group) for the user - /// - /// - /// - public Task RemoveFromRoleAsync(BackOfficeIdentityUser user, string roleName) - { - ThrowIfDisposed(); - if (user == null) throw new ArgumentNullException(nameof(user)); - if (roleName == null) throw new ArgumentNullException(nameof(roleName)); - if (string.IsNullOrWhiteSpace(roleName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(roleName)); - - var userRole = user.Roles.SingleOrDefault(r => r.RoleId == roleName); - - if (userRole != null) - { - user.Roles.Remove(userRole); - } - - return Task.FromResult(0); - } - - /// - /// Returns the roles (user groups) for this user - /// - /// - /// - public Task> GetRolesAsync(BackOfficeIdentityUser user) - { - ThrowIfDisposed(); - if (user == null) throw new ArgumentNullException(nameof(user)); - return Task.FromResult((IList)user.Roles.Select(x => x.RoleId).ToList()); - } - - /// - /// Returns true if a user is in the role - /// - /// - /// - public Task IsInRoleAsync(BackOfficeIdentityUser user, string roleName) - { - ThrowIfDisposed(); - if (user == null) throw new ArgumentNullException(nameof(user)); - return Task.FromResult(user.Roles.Select(x => x.RoleId).InvariantContains(roleName)); - } - - /// - /// Set the security stamp for the user - /// - /// - /// - public Task SetSecurityStampAsync(BackOfficeIdentityUser user, string stamp) - { - ThrowIfDisposed(); - if (user == null) throw new ArgumentNullException(nameof(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(nameof(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.GenerateHash() - : user.SecurityStamp); - } - - private BackOfficeIdentityUser AssignLoginsCallback(BackOfficeIdentityUser user) - { - if (user != null) - { - user.SetLoginsCallback(new Lazy>(() => - _externalLoginService.GetAll(user.Id))); - } - return user; - } - - /// - /// Sets whether two factor authentication is enabled for the user - /// - /// - /// - public virtual Task SetTwoFactorEnabledAsync(BackOfficeIdentityUser user, bool enabled) - { - user.TwoFactorEnabled = false; - return Task.FromResult(0); - } - - /// - /// Returns whether two factor authentication is enabled for the user - /// - /// - /// - public virtual Task GetTwoFactorEnabledAsync(BackOfficeIdentityUser user) - { - return Task.FromResult(false); - } - - #region IUserLockoutStore - - /// - /// Returns the DateTimeOffset that represents the end of a user's lockout, any time in the past should be considered not locked out. - /// - /// - /// - /// - /// Currently we do not support a timed lock out, when they are locked out, an admin will have to reset the status - /// - public Task GetLockoutEndDateAsync(BackOfficeIdentityUser user) - { - if (user == null) throw new ArgumentNullException(nameof(user)); - - return user.LockoutEndDateUtc.HasValue - ? Task.FromResult(DateTimeOffset.MaxValue) - : Task.FromResult(DateTimeOffset.MinValue); - } - - /// - /// Locks a user out until the specified end date (set to a past date, to unlock a user) - /// - /// - /// - /// - /// Currently we do not support a timed lock out, when they are locked out, an admin will have to reset the status - /// - public Task SetLockoutEndDateAsync(BackOfficeIdentityUser user, DateTimeOffset lockoutEnd) - { - if (user == null) throw new ArgumentNullException(nameof(user)); - user.LockoutEndDateUtc = lockoutEnd.UtcDateTime; - return Task.FromResult(0); - } - - /// - /// Used to record when an attempt to access the user has failed - /// - /// - /// - public Task IncrementAccessFailedCountAsync(BackOfficeIdentityUser user) - { - if (user == null) throw new ArgumentNullException(nameof(user)); - user.AccessFailedCount++; - return Task.FromResult(user.AccessFailedCount); - } - - /// - /// Used to reset the access failed count, typically after the account is successfully accessed - /// - /// - /// - public Task ResetAccessFailedCountAsync(BackOfficeIdentityUser user) - { - if (user == null) throw new ArgumentNullException(nameof(user)); - user.AccessFailedCount = 0; - return Task.FromResult(0); - } - - /// - /// Returns the current number of failed access attempts. This number usually will be reset whenever the password is - /// verified or the account is locked out. - /// - /// - /// - public Task GetAccessFailedCountAsync(BackOfficeIdentityUser user) - { - if (user == null) throw new ArgumentNullException(nameof(user)); - return Task.FromResult(user.AccessFailedCount); - } - - /// - /// Returns true - /// - /// - /// - public Task GetLockoutEnabledAsync(BackOfficeIdentityUser user) - { - if (user == null) throw new ArgumentNullException(nameof(user)); - return Task.FromResult(user.LockoutEnabled); - } - - /// - /// Doesn't actually perform any function, users can always be locked out - /// - /// - /// - public Task SetLockoutEnabledAsync(BackOfficeIdentityUser user, bool enabled) - { - if (user == null) throw new ArgumentNullException(nameof(user)); - user.LockoutEnabled = enabled; - return Task.FromResult(0); - } - #endregion - - private bool UpdateMemberProperties(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 (identityUser.IsPropertyDirty("LastLoginDateUtc") - || (user.LastLoginDate != default(DateTime) && identityUser.LastLoginDateUtc.HasValue == false) - || identityUser.LastLoginDateUtc.HasValue && user.LastLoginDate.ToUniversalTime() != identityUser.LastLoginDateUtc.Value) - { - anythingChanged = true; - //if the LastLoginDate is being set to MinValue, don't convert it ToLocalTime - var dt = identityUser.LastLoginDateUtc == DateTime.MinValue ? DateTime.MinValue : identityUser.LastLoginDateUtc.Value.ToLocalTime(); - user.LastLoginDate = dt; - } - if (identityUser.IsPropertyDirty("LastPasswordChangeDateUtc") - || (user.LastPasswordChangeDate != default(DateTime) && identityUser.LastPasswordChangeDateUtc.HasValue == false) - || identityUser.LastPasswordChangeDateUtc.HasValue && user.LastPasswordChangeDate.ToUniversalTime() != identityUser.LastPasswordChangeDateUtc.Value) - { - anythingChanged = true; - user.LastPasswordChangeDate = identityUser.LastPasswordChangeDateUtc.Value.ToLocalTime(); - } - if (identityUser.IsPropertyDirty("EmailConfirmed") - || (user.EmailConfirmedDate.HasValue && user.EmailConfirmedDate.Value != default(DateTime) && identityUser.EmailConfirmed == false) - || ((user.EmailConfirmedDate.HasValue == false || user.EmailConfirmedDate.Value == default(DateTime)) && identityUser.EmailConfirmed)) - { - anythingChanged = true; - user.EmailConfirmedDate = identityUser.EmailConfirmed ? (DateTime?)DateTime.Now : null; - } - if (identityUser.IsPropertyDirty("Name") - && user.Name != identityUser.Name && identityUser.Name.IsNullOrWhiteSpace() == false) - { - anythingChanged = true; - user.Name = identityUser.Name; - } - if (identityUser.IsPropertyDirty("Email") - && user.Email != identityUser.Email && identityUser.Email.IsNullOrWhiteSpace() == false) - { - anythingChanged = true; - user.Email = identityUser.Email; - } - if (identityUser.IsPropertyDirty("AccessFailedCount") - && user.FailedPasswordAttempts != identityUser.AccessFailedCount) - { - anythingChanged = true; - user.FailedPasswordAttempts = identityUser.AccessFailedCount; - } - if (user.IsLockedOut != identityUser.IsLockedOut) - { - anythingChanged = true; - user.IsLockedOut = identityUser.IsLockedOut; - - if (user.IsLockedOut) - { - //need to set the last lockout date - user.LastLockoutDate = DateTime.Now; - } - - } - if (identityUser.IsPropertyDirty("UserName") - && user.Username != identityUser.UserName && identityUser.UserName.IsNullOrWhiteSpace() == false) - { - anythingChanged = true; - user.Username = identityUser.UserName; - } - if (identityUser.IsPropertyDirty("PasswordHash") - && user.RawPasswordValue != identityUser.PasswordHash && identityUser.PasswordHash.IsNullOrWhiteSpace() == false) - { - anythingChanged = true; - user.RawPasswordValue = identityUser.PasswordHash; - } - - if (identityUser.IsPropertyDirty("Culture") - && user.Language != identityUser.Culture && identityUser.Culture.IsNullOrWhiteSpace() == false) - { - anythingChanged = true; - user.Language = identityUser.Culture; - } - if (identityUser.IsPropertyDirty("StartMediaIds") - && user.StartMediaIds.UnsortedSequenceEqual(identityUser.StartMediaIds) == false) - { - anythingChanged = true; - user.StartMediaIds = identityUser.StartMediaIds; - } - if (identityUser.IsPropertyDirty("StartContentIds") - && user.StartContentIds.UnsortedSequenceEqual(identityUser.StartContentIds) == false) - { - anythingChanged = true; - user.StartContentIds = identityUser.StartContentIds; - } - if (user.SecurityStamp != identityUser.SecurityStamp) - { - anythingChanged = true; - user.SecurityStamp = identityUser.SecurityStamp; - } - - // TODO: Fix this for Groups too - if (identityUser.IsPropertyDirty("Roles") || identityUser.IsPropertyDirty("Groups")) - { - var userGroupAliases = user.Groups.Select(x => x.Alias).ToArray(); - - var identityUserRoles = identityUser.Roles.Select(x => x.RoleId).ToArray(); - var identityUserGroups = identityUser.Groups.Select(x => x.Alias).ToArray(); - - var combinedAliases = identityUserRoles.Union(identityUserGroups).ToArray(); - - if (userGroupAliases.ContainsAll(combinedAliases) == false - || combinedAliases.ContainsAll(userGroupAliases) == false) - { - anythingChanged = true; - - //clear out the current groups (need to ToArray since we are modifying the iterator) - user.ClearGroups(); - - //go lookup all these groups - var groups = _userService.GetUserGroupsByAlias(combinedAliases).Select(x => x.ToReadOnlyGroup()).ToArray(); - - //use all of the ones assigned and add them - foreach (var group in groups) - { - user.AddGroup(group); - } - - //re-assign - identityUser.Groups = groups; - } - } - - //we should re-set the calculated start nodes - identityUser.CalculatedMediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService); - identityUser.CalculatedContentStartNodeIds = user.CalculateContentStartNodeIds(_entityService); - - //reset all changes - identityUser.ResetDirtyProperties(false); - - return anythingChanged; - } - - - private void ThrowIfDisposed() - { - if (_disposed) - throw new ObjectDisposedException(GetType().Name); - } - - public Task ValidateSessionIdAsync(int userId, string sessionId) - { - Guid guidSessionId; - if (Guid.TryParse(sessionId, out guidSessionId)) - { - return Task.FromResult(_userService.ValidateLoginSession(userId, guidSessionId)); - } - return Task.FromResult(false); - } - } -} diff --git a/src/Umbraco.Web/Security/BackOfficeUserStore2.cs b/src/Umbraco.Web/Security/BackOfficeUserStore2.cs index 65de3191de..d5ed266f4f 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserStore2.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserStore2.cs @@ -28,7 +28,7 @@ namespace Umbraco.Web.Security IUserSecurityStampStore, IUserLockoutStore, IUserTwoFactorStore, - IUserSessionStore2 + IUserSessionStore2 // TODO: This would require additional columns/tables for now people will need to implement this on their own //IUserPhoneNumberStore, @@ -119,9 +119,9 @@ namespace Umbraco.Web.Security // 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. //this will hash the guid with a salt so should be nicely random - var aspHasher = new Microsoft.AspNet.Identity.PasswordHasher(); + var aspHasher = new PasswordHasher(); var emptyPasswordValue = Constants.Security.EmptyPasswordPrefix + - aspHasher.HashPassword(Guid.NewGuid().ToString("N")); + aspHasher.HashPassword(user, Guid.NewGuid().ToString("N")); var userEntity = new User(_globalSettings, user.Name, user.Email, user.UserName, emptyPasswordValue) { @@ -902,12 +902,13 @@ namespace Umbraco.Web.Security throw new ObjectDisposedException(GetType().Name); } - public Task ValidateSessionIdAsync(int userId, string sessionId) + public Task ValidateSessionIdAsync(string userId, string sessionId) { Guid guidSessionId; if (Guid.TryParse(sessionId, out guidSessionId)) { - return Task.FromResult(_userService.ValidateLoginSession(userId, guidSessionId)); + // TODO: SB: Normalize string to int conversion + return Task.FromResult(_userService.ValidateLoginSession(int.Parse(sessionId), guidSessionId)); } return Task.FromResult(false); } diff --git a/src/Umbraco.Web/Security/BackOfficeUserValidator.cs b/src/Umbraco.Web/Security/BackOfficeUserValidator.cs deleted file mode 100644 index 0f6b9aa1d4..0000000000 --- a/src/Umbraco.Web/Security/BackOfficeUserValidator.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.AspNet.Identity; -using Umbraco.Web.Models.Identity; - -namespace Umbraco.Core.Security -{ - /// - /// Custom validator to not validate a user's username or email if they haven't changed - /// - /// - internal class BackOfficeUserValidator : UserValidator - where T : BackOfficeIdentityUser - { - public BackOfficeUserValidator(UserManager manager) : base(manager) - { - } - - public override async Task ValidateAsync(T item) - { - //Don't validate if the user's email or username hasn't changed otherwise it's just wasting SQL queries. - if (item.IsPropertyDirty("Email") || item.IsPropertyDirty("UserName")) - { - return await base.ValidateAsync(item); - } - return IdentityResult.Success; - } - } -} diff --git a/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs index 283f6b6b99..021461916f 100644 --- a/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs +++ b/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs @@ -1,8 +1,6 @@ using System; -using Microsoft.AspNet.Identity.Owin; using Umbraco.Core; using Umbraco.Web.Composing; -using Umbraco.Core.Models.Identity; using Umbraco.Web.Models.Identity; namespace Umbraco.Web.Security @@ -33,22 +31,22 @@ namespace Umbraco.Web.Security /// /// A callback executed during account auto-linking and before the user is persisted /// - public Action OnAutoLinking { get; set; } + public Action OnAutoLinking { get; set; } /// /// A callback executed during every time a user authenticates using an external login. /// returns a boolean indicating if sign in should continue or not. /// - public Func OnExternalLogin { get; set; } + public Func OnExternalLogin { get; set; } - /// + /// B /// The default User group aliases to use for auto-linking users /// /// /// /// - public string[] GetDefaultUserGroups(IUmbracoContext umbracoContext, ExternalLoginInfo loginInfo) + public string[] GetDefaultUserGroups(IUmbracoContext umbracoContext, ExternalLoginInfo2 loginInfo) { return _defaultUserGroups; } @@ -61,7 +59,7 @@ namespace Umbraco.Web.Security /// /// For public auth providers this should always be false!!! /// - public bool ShouldAutoLinkExternalAccount(IUmbracoContext umbracoContext, ExternalLoginInfo loginInfo) + public bool ShouldAutoLinkExternalAccount(IUmbracoContext umbracoContext, ExternalLoginInfo2 loginInfo) { return _autoLinkExternalAccount; } @@ -71,7 +69,7 @@ namespace Umbraco.Web.Security /// /// The default Culture to use for auto-linking users /// - public string GetDefaultCulture(IUmbracoContext umbracoContext, ExternalLoginInfo loginInfo) + public string GetDefaultCulture(IUmbracoContext umbracoContext, ExternalLoginInfo2 loginInfo) { return _defaultCulture; } diff --git a/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs b/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs index 92fea0bf40..93b51eec21 100644 --- a/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs +++ b/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs @@ -11,7 +11,7 @@ namespace Umbraco.Web.Security /// internal interface IBackOfficeUserManagerMarker { - BackOfficeUserManager GetManager(IOwinContext owin); + BackOfficeUserManager2 GetManager(IOwinContext owin); } /// /// This interface is only here due to the fact that IOwinContext Get / Set only work in generics, if they worked diff --git a/src/Umbraco.Web/Security/IUserAwarePasswordHasher.cs b/src/Umbraco.Web/Security/IUserAwarePasswordHasher.cs deleted file mode 100644 index 4af6d7accd..0000000000 --- a/src/Umbraco.Web/Security/IUserAwarePasswordHasher.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using Microsoft.AspNet.Identity; - -namespace Umbraco.Core.Security -{ - /// - /// A password hasher that is User aware so that it can process the hashing based on the user's settings - /// - /// - /// - public interface IUserAwarePasswordHasher : Microsoft.AspNet.Identity.IPasswordHasher - where TUser : class, IUser - where TKey : IEquatable - { - string HashPassword(TUser user, string password); - PasswordVerificationResult VerifyHashedPassword(TUser user, string hashedPassword, string providedPassword); - } -} diff --git a/src/Umbraco.Web/Security/IUserSessionStore.cs b/src/Umbraco.Web/Security/IUserSessionStore.cs index 524017d4ad..d410504ef7 100644 --- a/src/Umbraco.Web/Security/IUserSessionStore.cs +++ b/src/Umbraco.Web/Security/IUserSessionStore.cs @@ -1,6 +1,5 @@ -using System; using System.Threading.Tasks; -using Microsoft.AspNet.Identity; +using Microsoft.AspNetCore.Identity; namespace Umbraco.Core.Security { @@ -8,16 +7,9 @@ namespace Umbraco.Core.Security /// An IUserStore interface part to implement if the store supports validating user session Ids /// /// - /// - public interface IUserSessionStore : IUserStore, IDisposable - where TUser : class, IUser - { - Task ValidateSessionIdAsync(int userId, string sessionId); - } - - public interface IUserSessionStore2 : Microsoft.AspNetCore.Identity.IUserStore, IDisposable + public interface IUserSessionStore2 : IUserStore where TUser : class { - Task ValidateSessionIdAsync(int userId, string sessionId); + Task ValidateSessionIdAsync(string userId, string sessionId); // TODO: SB: Should take a user??? } } diff --git a/src/Umbraco.Web/Security/SessionIdValidator.cs b/src/Umbraco.Web/Security/SessionIdValidator.cs index 8f6782aac2..2e2afa8da5 100644 --- a/src/Umbraco.Web/Security/SessionIdValidator.cs +++ b/src/Umbraco.Web/Security/SessionIdValidator.cs @@ -2,15 +2,12 @@ using System; using System.Security.Claims; using System.Threading.Tasks; using System.Web; -using Microsoft.AspNet.Identity; -using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin; using Microsoft.Owin.Infrastructure; using Microsoft.Owin.Security.Cookies; using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.IO; -using Umbraco.Core.Security; using Constants = Umbraco.Core.Constants; namespace Umbraco.Web.Security @@ -88,16 +85,16 @@ namespace Umbraco.Web.Security if (validate == false) return true; - var manager = owinCtx.GetUserManager(); + var manager = owinCtx.Get(); if (manager == null) return false; - var userId = currentIdentity.GetUserId(); + var userId = currentIdentity.GetUserId(); var user = await manager.FindByIdAsync(userId); if (user == null) return false; - var sessionId = currentIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + var sessionId = currentIdentity.FindFirst(Constants.Security.SessionIdClaimType)?.Value; if (await manager.ValidateSessionIdAsync(userId, sessionId) == false) return false; diff --git a/src/Umbraco.Web/Security/UserAwarePasswordHasher.cs b/src/Umbraco.Web/Security/UserAwarePasswordHasher.cs deleted file mode 100644 index bbfc4905bf..0000000000 --- a/src/Umbraco.Web/Security/UserAwarePasswordHasher.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using Microsoft.AspNet.Identity; -using Umbraco.Web.Models.Identity; - -namespace Umbraco.Core.Security -{ - /// - /// The default password hasher that is User aware so that it can process the hashing based on the user's settings - /// - public class UserAwarePasswordHasher : IUserAwarePasswordHasher - { - private readonly PasswordSecurity _passwordSecurity; - - public UserAwarePasswordHasher(PasswordSecurity passwordSecurity) - { - _passwordSecurity = passwordSecurity; - } - - public string HashPassword(string password) - { - return _passwordSecurity.HashPasswordForStorage(password); - } - - public string HashPassword(BackOfficeIdentityUser user, string password) - { - // TODO: Implement the logic for this, we need to lookup the password format for the user and hash accordingly: http://issues.umbraco.org/issue/U4-10089 - //NOTE: For now this just falls back to the hashing we are currently using - - return HashPassword(password); - } - - public PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword) - { - return _passwordSecurity.VerifyPassword(providedPassword, hashedPassword) - ? PasswordVerificationResult.Success - : PasswordVerificationResult.Failed; - } - - public PasswordVerificationResult VerifyHashedPassword(BackOfficeIdentityUser user, string hashedPassword, string providedPassword) - { - // TODO: Implement the logic for this, we need to lookup the password format for the user and hash accordingly: http://issues.umbraco.org/issue/U4-10089 - //NOTE: For now this just falls back to the hashing we are currently using - - return VerifyHashedPassword(hashedPassword, providedPassword); - } - } -} diff --git a/src/Umbraco.Web/Security/WebSecurity.cs b/src/Umbraco.Web/Security/WebSecurity.cs index 6f16eb3c52..08a2626cb3 100644 --- a/src/Umbraco.Web/Security/WebSecurity.cs +++ b/src/Umbraco.Web/Security/WebSecurity.cs @@ -1,11 +1,9 @@ using System; -using System.Linq; using System.Security; using System.Web; using Umbraco.Core; using Umbraco.Core.Services; using Umbraco.Core.Models.Membership; -using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin; using Umbraco.Core.Configuration; using Umbraco.Core.IO; @@ -54,17 +52,17 @@ namespace Umbraco.Web.Security } } - private BackOfficeSignInManager _signInManager; - private BackOfficeSignInManager SignInManager + private BackOfficeSignInManager2 _signInManager; + private BackOfficeSignInManager2 SignInManager { get { if (_signInManager == null) { - var mgr = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().Get(); + var mgr = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().Get(); if (mgr == null) { - throw new NullReferenceException("Could not resolve an instance of " + typeof(BackOfficeSignInManager) + " from the " + typeof(IOwinContext)); + throw new NullReferenceException("Could not resolve an instance of " + typeof(BackOfficeSignInManager2) + " from the " + typeof(IOwinContext)); } _signInManager = mgr; } @@ -72,9 +70,9 @@ namespace Umbraco.Web.Security } } - private BackOfficeUserManager _userManager; - protected BackOfficeUserManager UserManager - => _userManager ?? (_userManager = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().GetBackOfficeUserManager()); + private BackOfficeUserManager2 _userManager; + protected BackOfficeUserManager2 UserManager + => _userManager ?? (_userManager = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().GetBackOfficeUserManager2()); [Obsolete("This needs to be removed, ASP.NET Identity should always be used for this operation, this is currently only used in the installer which needs to be updated")] public double PerformLogin(int userId) @@ -84,7 +82,7 @@ namespace Umbraco.Web.Security //ensure it's done for owin too owinCtx.Authentication.SignOut(Constants.Security.BackOfficeExternalAuthenticationType); - var user = UserManager.FindByIdAsync(userId).Result; + var user = UserManager.FindByIdAsync(userId.ToString()).Result; SignInManager.SignInAsync(user, isPersistent: true, rememberBrowser: false).Wait(); diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index aed9a34b82..d124a96209 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -200,13 +200,11 @@ - + - - @@ -214,7 +212,6 @@ - @@ -230,7 +227,6 @@ - @@ -247,8 +243,6 @@ - - diff --git a/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs b/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs index a30f77b217..9fd8ab7baa 100644 --- a/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs +++ b/src/Umbraco.Web/UmbracoDefaultOwinStartup.cs @@ -88,9 +88,8 @@ namespace Umbraco.Web // (EXPERT: an overload accepts a custom BackOfficeUserStore implementation) app.ConfigureUserManagerForUmbracoBackOffice( Services, - Mapper, - UmbracoSettings.Content, GlobalSettings, + Mapper, UserPasswordConfig, IpResolver); } From c8afd85bd38ef0dcc0b6aabc317ed7628ea2a677 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Thu, 2 Apr 2020 08:08:09 +0100 Subject: [PATCH 07/34] Initial run --- .../Editors/CurrentUserController.cs | 2 +- src/Umbraco.Web/OwinExtensions.cs | 33 +------- .../Security/AppBuilderExtensions.cs | 12 --- .../BackOfficeClaimsIdentityFactory.cs | 51 ------------ .../BackOfficeClaimsPrincipalFactory.cs | 41 ++++++++++ .../Security/BackOfficeSignInManager2.cs | 21 +++-- .../Security/BackOfficeUserManager2.cs | 79 +++++++------------ .../Security/NopLookupNormalizer.cs | 13 +++ src/Umbraco.Web/Umbraco.Web.csproj | 3 +- .../CheckIfUserTicketDataIsStaleAttribute.cs | 2 +- 10 files changed, 105 insertions(+), 152 deletions(-) delete mode 100644 src/Umbraco.Web/Security/BackOfficeClaimsIdentityFactory.cs create mode 100644 src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs create mode 100644 src/Umbraco.Web/Security/NopLookupNormalizer.cs diff --git a/src/Umbraco.Web/Editors/CurrentUserController.cs b/src/Umbraco.Web/Editors/CurrentUserController.cs index 35ec09eaca..cbee8f6e2d 100644 --- a/src/Umbraco.Web/Editors/CurrentUserController.cs +++ b/src/Umbraco.Web/Editors/CurrentUserController.cs @@ -211,7 +211,7 @@ namespace Umbraco.Web.Editors if (passwordChangeResult.Success) { - var userMgr = this.TryGetOwinContext().Result.GetBackOfficeUserManager(); + var userMgr = this.TryGetOwinContext().Result.GetBackOfficeUserManager2(); //even if we weren't resetting this, it is the correct value (null), otherwise if we were resetting then it will contain the new pword var result = new ModelWithNotifications(passwordChangeResult.Result.ResetPassword); diff --git a/src/Umbraco.Web/OwinExtensions.cs b/src/Umbraco.Web/OwinExtensions.cs index 66e7e5353e..c27e466a7e 100644 --- a/src/Umbraco.Web/OwinExtensions.cs +++ b/src/Umbraco.Web/OwinExtensions.cs @@ -51,18 +51,7 @@ namespace Umbraco.Web var ctx = owinContext.Get(typeof(HttpContextBase).FullName); return ctx == null ? Attempt.Fail() : Attempt.Succeed(ctx); } - - /// - /// Gets the back office sign in manager out of OWIN - /// - /// - /// - public static BackOfficeSignInManager2 GetBackOfficeSignInManager(this IOwinContext owinContext) - { - return owinContext.Get() - ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager2)} from the {typeof(IOwinContext)}."); - } - + /// /// Gets the back office sign in manager out of OWIN /// @@ -74,24 +63,6 @@ namespace Umbraco.Web ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager2)} from the {typeof(IOwinContext)}."); } - /// - /// Gets the back office user manager out of OWIN - /// - /// - /// - /// - /// This is required because to extract the user manager we need to user a custom service since owin only deals in generics and - /// developers could register their own user manager types - /// - public static BackOfficeUserManager2 GetBackOfficeUserManager(this IOwinContext owinContext) - { - var marker = owinContext.Get(BackOfficeUserManager2.OwinMarkerKey) - ?? throw new NullReferenceException($"No {typeof (IBackOfficeUserManagerMarker)}, i.e. no Umbraco back-office, has been registered with Owin."); - - return marker.GetManager(owinContext) - ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager2)} from the {typeof (IOwinContext)}."); - } - /// /// Gets the back office user manager out of OWIN /// @@ -110,8 +81,6 @@ namespace Umbraco.Web ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager2)} from the {typeof (IOwinContext)}."); } - // TODO: SB: OWIN DI - /// /// Adapted from Microsoft.AspNet.Identity.Owin.OwinContextExtensions /// diff --git a/src/Umbraco.Web/Security/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/AppBuilderExtensions.cs index e7f2d0272a..e88d2fbacb 100644 --- a/src/Umbraco.Web/Security/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/AppBuilderExtensions.cs @@ -2,7 +2,6 @@ using System.Threading; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using Microsoft.Owin.Extensions; using Microsoft.Owin.Logging; using Microsoft.Owin.Security; @@ -16,7 +15,6 @@ using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.IO; using Umbraco.Core.Mapping; -using Umbraco.Core.Security; using Umbraco.Core.Services; using Umbraco.Net; using Umbraco.Web.Composing; @@ -53,11 +51,6 @@ namespace Umbraco.Web.Security mapper, passwordConfiguration, ipResolver, - new OptionsWrapper(new IdentityOptions()), - new UserAwarePasswordHasher2(new PasswordSecurity(passwordConfiguration)), - new[] {new UserValidator(),}, - new[] {new PasswordValidator()}, - new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null, new NullLogger>())); @@ -88,11 +81,6 @@ namespace Umbraco.Web.Security passwordConfiguration, ipResolver, customUserStore, - new OptionsWrapper(new IdentityOptions()), - new UserAwarePasswordHasher2(new PasswordSecurity(passwordConfiguration)), - new[] { new Microsoft.AspNetCore.Identity.UserValidator(), }, - new[] { new PasswordValidator() }, - new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null, new NullLogger>())); diff --git a/src/Umbraco.Web/Security/BackOfficeClaimsIdentityFactory.cs b/src/Umbraco.Web/Security/BackOfficeClaimsIdentityFactory.cs deleted file mode 100644 index 487e16539b..0000000000 --- a/src/Umbraco.Web/Security/BackOfficeClaimsIdentityFactory.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Linq; -using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.AspNet.Identity; -using Umbraco.Core; -using Umbraco.Core.Models.Identity; -using Umbraco.Core.Security; -using Umbraco.Web.Models.Identity; -using Constants = Umbraco.Core.Constants; - -namespace Umbraco.Web.Security -{ - public class BackOfficeClaimsIdentityFactory : ClaimsIdentityFactory - where T: BackOfficeIdentityUser - { - public BackOfficeClaimsIdentityFactory() - { - SecurityStampClaimType = Constants.Security.SessionIdClaimType; - UserNameClaimType = ClaimTypes.Name; - } - - /// - /// Create a ClaimsIdentity from a user - /// - /// - /// - public override async Task CreateAsync(UserManager manager, T user, string authenticationType) - { - var baseIdentity = await base.CreateAsync(manager, user, authenticationType); - - var umbracoIdentity = new UmbracoBackOfficeIdentity(baseIdentity, - user.Id, - user.UserName, - user.Name, - user.CalculatedContentStartNodeIds, - user.CalculatedMediaStartNodeIds, - user.Culture, - //NOTE - there is no session id assigned here, this is just creating the identity, a session id will be generated when the cookie is written - Guid.NewGuid().ToString(), - user.SecurityStamp, - user.AllowedSections, - user.Roles.Select(x => x.RoleId).ToArray()); - - return umbracoIdentity; - } - } - - public class BackOfficeClaimsIdentityFactory : BackOfficeClaimsIdentityFactory - { } -} diff --git a/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs b/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs new file mode 100644 index 0000000000..2a90650d97 --- /dev/null +++ b/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Umbraco.Core.Security; +using Umbraco.Web.Models.Identity; + +namespace Umbraco.Web.Security +{ + public class BackOfficeClaimsPrincipalFactory : UserClaimsPrincipalFactory + where TUser : BackOfficeIdentityUser + { + public BackOfficeClaimsPrincipalFactory(UserManager userManager, IOptions optionsAccessor) + : base(userManager, optionsAccessor) + { + } + + public override async Task CreateAsync(TUser user) + { + var claimsPrincipal = await base.CreateAsync(user); + + var umbracoIdentity = new UmbracoBackOfficeIdentity( + claimsPrincipal.Identity as ClaimsIdentity, + user.Id, + user.UserName, + user.Name, + user.CalculatedContentStartNodeIds, + user.CalculatedMediaStartNodeIds, + user.Culture, + //NOTE - there is no session id assigned here, this is just creating the identity, a session id will be generated when the cookie is written + Guid.NewGuid().ToString(), + user.SecurityStamp, + user.AllowedSections, + user.Roles.Select(x => x.RoleId).ToArray()); + + return new ClaimsPrincipal(umbracoIdentity); + } + } +} diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs index 37284cc04c..1f2d4f0bb9 100644 --- a/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs +++ b/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; using Microsoft.Owin; using Microsoft.Owin.Logging; using Microsoft.Owin.Security; @@ -16,11 +17,13 @@ namespace Umbraco.Web.Security { /// /// Custom sign in manager due to SignInManager not being .NET Standard. + /// Code ported from Umbraco's BackOfficeSignInManager. /// Can be removed once the web project moves to .NET Core. /// public class BackOfficeSignInManager2 : IDisposable { private readonly BackOfficeUserManager2 _userManager; + private readonly IUserClaimsPrincipalFactory _claimsPrincipalFactory; private readonly IAuthenticationManager _authenticationManager; private readonly ILogger _logger; private readonly IGlobalSettings _globalSettings; @@ -28,27 +31,35 @@ namespace Umbraco.Web.Security public BackOfficeSignInManager2( BackOfficeUserManager2 userManager, + IUserClaimsPrincipalFactory claimsPrincipalFactory, IAuthenticationManager authenticationManager, ILogger logger, IGlobalSettings globalSettings, IOwinRequest request) { _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); + _claimsPrincipalFactory = claimsPrincipalFactory ?? throw new ArgumentNullException(nameof(claimsPrincipalFactory)); _authenticationManager = authenticationManager ?? throw new ArgumentNullException(nameof(authenticationManager)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _globalSettings = globalSettings ?? throw new ArgumentNullException(nameof(globalSettings)); _request = request ?? throw new ArgumentNullException(nameof(request)); } - public Task CreateUserIdentityAsync(BackOfficeIdentityUser user) + public async Task CreateUserIdentityAsync(BackOfficeIdentityUser user) { - throw new NotImplementedException(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + var claimsPrincipal = await _claimsPrincipalFactory.CreateAsync(user); + return claimsPrincipal.Identity as ClaimsIdentity; } public static BackOfficeSignInManager2 Create(IOwinContext context, IGlobalSettings globalSettings, ILogger logger) { + var userManager = context.GetBackOfficeUserManager2(); + return new BackOfficeSignInManager2( - context.GetBackOfficeUserManager2(), + userManager, + new BackOfficeClaimsPrincipalFactory(userManager, new OptionsWrapper(userManager.Options)), context.Authentication, logger, globalSettings, @@ -146,7 +157,7 @@ namespace Umbraco.Web.Security if (requestContext != null) { - var backofficeUserManager = requestContext.GetBackOfficeUserManager(); + var backofficeUserManager = requestContext.GetBackOfficeUserManager2(); if (backofficeUserManager != null) backofficeUserManager.RaiseAccountLockedEvent(user.Id); } @@ -156,7 +167,7 @@ namespace Umbraco.Web.Security if (requestContext != null) { - var backofficeUserManager = requestContext.GetBackOfficeUserManager(); + var backofficeUserManager = requestContext.GetBackOfficeUserManager2(); if (backofficeUserManager != null) backofficeUserManager.RaiseInvalidLoginAttemptEvent(userName); } diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs index 0adc92b77e..27bea647a2 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs @@ -23,22 +23,19 @@ namespace Umbraco.Web.Security IIpResolver ipResolver, IUserStore store, IOptions optionsAccessor, - IPasswordHasher passwordHasher, IEnumerable> userValidators, IEnumerable> passwordValidators, ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, ILogger> logger) - : base(passwordConfiguration, ipResolver, store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) + : base(passwordConfiguration, ipResolver, store, optionsAccessor, userValidators, passwordValidators, keyNormalizer, errors, services, logger) { InitUserManager(this, passwordConfiguration); } #region Static Create methods - - // TODO: SB: Static Create methods for OWIN - + /// /// Creates a BackOfficeUserManager instance with all default options and the default BackOfficeUserManager /// @@ -50,25 +47,16 @@ namespace Umbraco.Web.Security UmbracoMapper mapper, IPasswordConfiguration passwordConfiguration, IIpResolver ipResolver, - IOptions optionsAccessor, - IPasswordHasher passwordHasher, - IEnumerable> userValidators, - IEnumerable> passwordValidators, - ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, ILogger> logger) { var store = new BackOfficeUserStore2(userService, entityService, externalLoginService, globalSettings, mapper); - return new BackOfficeUserManager2( + + return Create( passwordConfiguration, ipResolver, store, - optionsAccessor, - passwordHasher, - userValidators, - passwordValidators, - keyNormalizer, errors, services, logger); @@ -81,24 +69,39 @@ namespace Umbraco.Web.Security IPasswordConfiguration passwordConfiguration, IIpResolver ipResolver, IUserStore customUserStore, - IOptions optionsAccessor, - IPasswordHasher passwordHasher, - IEnumerable> userValidators, - IEnumerable> passwordValidators, - ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, ILogger> logger) { + var options = new IdentityOptions(); + + // Configure validation logic for usernames + var userValidators = new List> { new BackOfficeUserValidator2() }; + options.User.RequireUniqueEmail = true; + + // Configure validation logic for passwords + var passwordValidators = new List> { new PasswordValidator() }; + options.Password.RequiredLength = passwordConfiguration.RequiredLength; + options.Password.RequireNonAlphanumeric = passwordConfiguration.RequireNonLetterOrDigit; + options.Password.RequireDigit = passwordConfiguration.RequireDigit; + options.Password.RequireLowercase = passwordConfiguration.RequireLowercase; + options.Password.RequireUppercase = passwordConfiguration.RequireUppercase; + + options.Lockout.AllowedForNewUsers = true; + options.Lockout.MaxFailedAccessAttempts = passwordConfiguration.MaxFailedAccessAttemptsBeforeLockout; + //NOTE: This just needs to be in the future, we currently don't support a lockout timespan, it's either they are locked + // or they are not locked, but this determines what is set on the account lockout date which corresponds to whether they are + // locked out or not. + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(30); + return new BackOfficeUserManager2( passwordConfiguration, ipResolver, customUserStore, - optionsAccessor, - passwordHasher, + new OptionsWrapper(options), userValidators, passwordValidators, - keyNormalizer, + new NopLookupNormalizer(), errors, services, logger); @@ -117,14 +120,13 @@ namespace Umbraco.Web.Security IIpResolver ipResolver, IUserStore store, IOptions optionsAccessor, - IPasswordHasher passwordHasher, IEnumerable> userValidators, IEnumerable> passwordValidators, ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, ILogger> logger) - : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) + : base(store, optionsAccessor, null, userValidators, passwordValidators, keyNormalizer, errors, services, logger) { PasswordConfiguration = passwordConfiguration ?? throw new ArgumentNullException(nameof(passwordConfiguration)); IpResolver = ipResolver ?? throw new ArgumentNullException(nameof(ipResolver)); @@ -146,7 +148,6 @@ namespace Umbraco.Web.Security public override bool SupportsUserPhoneNumber => false; #endregion - // TODO: SB: INIT /// /// Initializes the user manager with the correct options /// @@ -158,22 +159,8 @@ namespace Umbraco.Web.Security IPasswordConfiguration passwordConfig) // IDataProtectionProvider dataProtectionProvider { - // Configure validation logic for usernames - manager.UserValidators.Clear(); - manager.UserValidators.Add(new BackOfficeUserValidator2()); - manager.Options.User.RequireUniqueEmail = true; - - // Configure validation logic for passwords - manager.PasswordValidators.Clear(); - manager.PasswordValidators.Add(new PasswordValidator()); - manager.Options.Password.RequiredLength = passwordConfig.RequiredLength; - manager.Options.Password.RequireNonAlphanumeric = passwordConfig.RequireNonLetterOrDigit; - manager.Options.Password.RequireDigit = passwordConfig.RequireDigit; - manager.Options.Password.RequireLowercase = passwordConfig.RequireLowercase; - manager.Options.Password.RequireUppercase = passwordConfig.RequireUppercase; - //use a custom hasher based on our membership provider - manager.PasswordHasher = GetDefaultPasswordHasher(passwordConfig); + PasswordHasher = GetDefaultPasswordHasher(PasswordConfiguration); // TODO: SB: manager.Options.Tokens using OWIN data protector /*if (dataProtectionProvider != null) @@ -184,12 +171,7 @@ namespace Umbraco.Web.Security }; }*/ - manager.Options.Lockout.AllowedForNewUsers = true; - manager.Options.Lockout.MaxFailedAccessAttempts = passwordConfig.MaxFailedAccessAttemptsBeforeLockout; - //NOTE: This just needs to be in the future, we currently don't support a lockout timespan, it's either they are locked - // or they are not locked, but this determines what is set on the account lockout date which corresponds to whether they are - // locked out or not. - manager.Options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(30); + } /// @@ -217,7 +199,6 @@ namespace Umbraco.Web.Security return new UserAwarePasswordHasher2(new PasswordSecurity(passwordConfiguration)); } - /// /// Gets/sets the default back office user password checker /// diff --git a/src/Umbraco.Web/Security/NopLookupNormalizer.cs b/src/Umbraco.Web/Security/NopLookupNormalizer.cs new file mode 100644 index 0000000000..211cea076e --- /dev/null +++ b/src/Umbraco.Web/Security/NopLookupNormalizer.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity; + +namespace Umbraco.Web.Security +{ + /// + /// No-op lookup normalizer to maintain compatibility with ASP.NET Identity 2 + /// + public class NopLookupNormalizer : ILookupNormalizer + { + public string NormalizeName(string name) => name; + public string NormalizeEmail(string email) => email; + } +} diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index d124a96209..6b1a25a15a 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -198,6 +198,7 @@ + @@ -208,6 +209,7 @@ + @@ -226,7 +228,6 @@ - diff --git a/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs b/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs index c68b949bba..2e29b44200 100644 --- a/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs @@ -113,7 +113,7 @@ namespace Umbraco.Web.WebApi.Filters var owinCtx = actionContext.Request.TryGetOwinContext(); if (owinCtx) { - var signInManager = owinCtx.Result.GetBackOfficeSignInManager(); + var signInManager = owinCtx.Result.GetBackOfficeSignInManager2(); var backOfficeIdentityUser = Mapper.Map(user); await signInManager.SignInAsync(backOfficeIdentityUser, isPersistent: true, rememberBrowser: false); From 4e583db68e3a23a7ddd6e9db9ef9e3f4d6667a74 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Thu, 2 Apr 2020 15:55:00 +0100 Subject: [PATCH 08/34] Added OWIN based token provider. Removed use of IdentityFactoryOptions --- .../Security/AppBuilderExtensions.cs | 32 +---- .../Security/BackOfficeUserManager2.cs | 40 +++--- .../OwinDataProtectorTokenProvider.cs | 130 ++++++++++++++++++ src/Umbraco.Web/Umbraco.Web.csproj | 1 + 4 files changed, 152 insertions(+), 51 deletions(-) create mode 100644 src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs diff --git a/src/Umbraco.Web/Security/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/AppBuilderExtensions.cs index e88d2fbacb..1222d10971 100644 --- a/src/Umbraco.Web/Security/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/AppBuilderExtensions.cs @@ -52,7 +52,7 @@ namespace Umbraco.Web.Security passwordConfiguration, ipResolver, new IdentityErrorDescriber(), - null, + app.GetDataProtectionProvider(), new NullLogger>())); app.SetBackOfficeUserManagerType(); @@ -82,7 +82,7 @@ namespace Umbraco.Web.Security ipResolver, customUserStore, new IdentityErrorDescriber(), - null, + app.GetDataProtectionProvider(), new NullLogger>())); app.SetBackOfficeUserManagerType(); @@ -91,34 +91,6 @@ namespace Umbraco.Web.Security app.CreatePerOwinContext((options, context) => BackOfficeSignInManager2.Create(context, globalSettings, app.CreateLogger(typeof(BackOfficeSignInManager2).FullName))); } - // TODO: SB: ConfigureUserManagerForUmbracoBackOffice using IdentityFactoryOptions - /*/// - /// Configure a custom BackOfficeUserManager for Umbraco - /// - /// - /// - /// - /// - public static void ConfigureUserManagerForUmbracoBackOffice(this IAppBuilder app, - IRuntimeState runtimeState, - IGlobalSettings globalSettings, - Func, IOwinContext, TManager> userManager) - where TManager : BackOfficeUserManager2 - where TUser : BackOfficeIdentityUser - { - if (runtimeState == null) throw new ArgumentNullException(nameof(runtimeState)); - if (userManager == null) throw new ArgumentNullException(nameof(userManager)); - - //Configure Umbraco user manager to be created per request - app.CreatePerOwinContext(userManager); - - app.SetBackOfficeUserManagerType(); - - //Create a sign in manager per request - app.CreatePerOwinContext( - (options, context) => BackOfficeSignInManager2.Create(context, globalSettings, app.CreateLogger(typeof(BackOfficeSignInManager2).FullName))); - }*/ - /// /// Ensures that the UmbracoBackOfficeAuthenticationMiddleware is assigned to the pipeline /// diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs index 27bea647a2..af83982647 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Owin.Security.DataProtection; using Umbraco.Core.Configuration; using Umbraco.Core.Mapping; using Umbraco.Core.Security; @@ -27,11 +28,11 @@ namespace Umbraco.Web.Security IEnumerable> passwordValidators, ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, - IServiceProvider services, + IDataProtectionProvider dataProtectionProvider, ILogger> logger) - : base(passwordConfiguration, ipResolver, store, optionsAccessor, userValidators, passwordValidators, keyNormalizer, errors, services, logger) + : base(passwordConfiguration, ipResolver, store, optionsAccessor, userValidators, passwordValidators, keyNormalizer, errors, null, logger) { - InitUserManager(this, passwordConfiguration); + InitUserManager(this, dataProtectionProvider); } #region Static Create methods @@ -48,7 +49,7 @@ namespace Umbraco.Web.Security IPasswordConfiguration passwordConfiguration, IIpResolver ipResolver, IdentityErrorDescriber errors, - IServiceProvider services, + IDataProtectionProvider dataProtectionProvider, ILogger> logger) { var store = new BackOfficeUserStore2(userService, entityService, externalLoginService, globalSettings, mapper); @@ -58,7 +59,7 @@ namespace Umbraco.Web.Security ipResolver, store, errors, - services, + dataProtectionProvider, logger); } @@ -70,7 +71,7 @@ namespace Umbraco.Web.Security IIpResolver ipResolver, IUserStore customUserStore, IdentityErrorDescriber errors, - IServiceProvider services, + IDataProtectionProvider dataProtectionProvider, ILogger> logger) { var options = new IdentityOptions(); @@ -103,7 +104,7 @@ namespace Umbraco.Web.Security passwordValidators, new NopLookupNormalizer(), errors, - services, + dataProtectionProvider, logger); } @@ -151,27 +152,24 @@ namespace Umbraco.Web.Security /// /// Initializes the user manager with the correct options /// - /// - /// - /// protected void InitUserManager( BackOfficeUserManager2 manager, - IPasswordConfiguration passwordConfig) - // IDataProtectionProvider dataProtectionProvider + IDataProtectionProvider dataProtectionProvider) { //use a custom hasher based on our membership provider PasswordHasher = GetDefaultPasswordHasher(PasswordConfiguration); - // TODO: SB: manager.Options.Tokens using OWIN data protector - /*if (dataProtectionProvider != null) + // TODO: SB: manager.Options.Tokens using OWIN data protector - what about the other providers??? + // https://github.com/dotnet/aspnetcore/blob/0a0e1ea0cdbe29f2fcd2291b900db98597387d77/src/Identity/Core/src/IdentityBuilderExtensions.cs#L28 + if (dataProtectionProvider != null) { - manager.UserTokenProvider = new DataProtectorTokenProvider(dataProtectionProvider.Create("ASP.NET Identity")) - { - TokenLifespan = TimeSpan.FromDays(3) - }; - }*/ - - + manager.RegisterTokenProvider( + "Default", + new OwinDataProtectorTokenProvider(dataProtectionProvider.Create("ASP.NET Identity")) + { + TokenLifespan = TimeSpan.FromDays(3) + }); + } } /// diff --git a/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs b/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs new file mode 100644 index 0000000000..4e90980478 --- /dev/null +++ b/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs @@ -0,0 +1,130 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Owin.Security.DataProtection; +using Umbraco.Web.Models.Identity; + +namespace Umbraco.Web.Security +{ + /// + /// Adapted from Microsoft.AspNet.Identity.Owin.DataProtectorTokenProvider + /// + public class OwinDataProtectorTokenProvider : IUserTwoFactorTokenProvider where TUser : BackOfficeIdentityUser + { + public TimeSpan TokenLifespan { get; set; } + private readonly IDataProtector _protector; + + public OwinDataProtectorTokenProvider(IDataProtector protector) + { + _protector = protector ?? throw new ArgumentNullException(nameof(protector)); + TokenLifespan = TimeSpan.FromDays(1); + } + + public async Task GenerateAsync(string purpose, UserManager manager, TUser user) + { + if (user == null) throw new ArgumentNullException(nameof(user)); + + var ms = new MemoryStream(); + using (var writer = ms.CreateWriter()) + { + writer.Write(DateTimeOffset.UtcNow); + writer.Write(Convert.ToString(user.Id, CultureInfo.InvariantCulture)); + writer.Write(purpose ?? ""); + + string stamp = null; + if (manager.SupportsUserSecurityStamp) + { + stamp = await manager.GetSecurityStampAsync(user); + } + writer.Write(stamp ?? ""); + } + + var protectedBytes = _protector.Protect(ms.ToArray()); + return Convert.ToBase64String(protectedBytes); + } + + public async Task ValidateAsync(string purpose, string token, UserManager manager, TUser user) + { + try + { + var unprotectedData = _protector.Unprotect(Convert.FromBase64String(token)); + var ms = new MemoryStream(unprotectedData); + using (var reader = ms.CreateReader()) + { + var creationTime = reader.ReadDateTimeOffset(); + var expirationTime = creationTime + TokenLifespan; + if (expirationTime < DateTimeOffset.UtcNow) + { + return false; + } + + var userId = reader.ReadString(); + if (!string.Equals(userId, Convert.ToString(user.Id, CultureInfo.InvariantCulture))) + { + return false; + } + + var purp = reader.ReadString(); + if (!string.Equals(purp, purpose)) + { + return false; + } + + var stamp = reader.ReadString(); + if (reader.PeekChar() != -1) + { + return false; + } + + if (manager.SupportsUserSecurityStamp) + { + var expectedStamp = await manager.GetSecurityStampAsync(user); + return stamp == expectedStamp; + } + + return stamp == ""; + } + } + // ReSharper disable once EmptyGeneralCatchClause + catch + { + // Do not leak exception + } + + return false; + } + + public Task CanGenerateTwoFactorTokenAsync(UserManager manager, TUser user) + { + return Task.FromResult(true); + } + } + + internal static class StreamExtensions + { + private static readonly Encoding DefaultEncoding = new UTF8Encoding(false, true); + + public static BinaryReader CreateReader(this Stream stream) + { + return new BinaryReader(stream, DefaultEncoding, true); + } + + public static BinaryWriter CreateWriter(this Stream stream) + { + return new BinaryWriter(stream, DefaultEncoding, true); + } + + public static DateTimeOffset ReadDateTimeOffset(this BinaryReader reader) + { + return new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero); + } + + public static void Write(this BinaryWriter writer, DateTimeOffset value) + { + writer.Write(value.UtcTicks); + } + } +} diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 6b1a25a15a..a53187f46b 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -210,6 +210,7 @@ + From 851ae1ba0fe62141ed0949bc7415dfc4e91380d5 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Sat, 4 Apr 2020 12:36:04 +0100 Subject: [PATCH 09/34] Initial working back office --- .../BackOfficeClaimsPrincipalFactoryTests.cs | 85 +++++++++++++++++++ src/Umbraco.Tests/Umbraco.Tests.csproj | 1 + .../BackOfficeClaimsPrincipalFactory.cs | 9 +- .../Security/BackOfficeUserManager2.cs | 6 +- .../Security/BackOfficeUserStore2.cs | 57 ++++++------- 5 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 src/Umbraco.Tests/Security/BackOfficeClaimsPrincipalFactoryTests.cs diff --git a/src/Umbraco.Tests/Security/BackOfficeClaimsPrincipalFactoryTests.cs b/src/Umbraco.Tests/Security/BackOfficeClaimsPrincipalFactoryTests.cs new file mode 100644 index 0000000000..6f55381a62 --- /dev/null +++ b/src/Umbraco.Tests/Security/BackOfficeClaimsPrincipalFactoryTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Core.Configuration; +using Umbraco.Core.Models.Membership; +using Umbraco.Core.Security; +using Umbraco.Web.Models.Identity; +using Umbraco.Web.Security; + +namespace Umbraco.Tests.Security +{ + [TestFixture] + public class BackOfficeClaimsPrincipalFactoryTests + { + private BackOfficeIdentityUser _testUser; + private Mock> _mockUserManager; + + [Test] + public async Task CreateAsync_Should_Create_Principal_With_Umbraco_Identity() + { + var sut = CreateSut(); + + var claimsPrincipal = await sut.CreateAsync(_testUser); + + var umbracoBackOfficeIdentity = claimsPrincipal.Identity as UmbracoBackOfficeIdentity; + Assert.IsNotNull(umbracoBackOfficeIdentity); + } + + [Test] + public async Task CreateAsync_Should_Create_NameId() + { + var sut = CreateSut(); + + var claimsPrincipal = await sut.CreateAsync(_testUser); + + Assert.True(claimsPrincipal.HasClaim(ClaimTypes.NameIdentifier, _testUser.Id.ToString())); + } + + [Test] + public async Task CreateAsync_Should_Create_IdentityProvider() + { + var sut = CreateSut(); + + var claimsPrincipal = await sut.CreateAsync(_testUser); + + Assert.True(claimsPrincipal.HasClaim( + "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", + "ASP.NET Identity")); + } + + [SetUp] + public void Setup() + { + var mockGlobalSettings = new Mock(); + mockGlobalSettings.Setup(x => x.DefaultUILanguage).Returns("test"); + + _testUser = new BackOfficeIdentityUser(mockGlobalSettings.Object, 2, new List()) + { + UserName = "bob", + Name = "Bob", + Email = "bob@umbraco.test", + SecurityStamp = "B6937738-9C17-4C7D-A25A-628A875F5177" + }; + + _mockUserManager = new Mock>(new Mock>().Object, + null, null, null, null, null, null, null, null); + _mockUserManager.Setup(x => x.GetUserIdAsync(_testUser)).ReturnsAsync(_testUser.Id.ToString); + _mockUserManager.Setup(x => x.GetUserNameAsync(_testUser)).ReturnsAsync(_testUser.UserName); + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(false); + _mockUserManager.Setup(x => x.SupportsUserClaim).Returns(false); + _mockUserManager.Setup(x => x.SupportsUserRole).Returns(false); + } + + private BackOfficeClaimsPrincipalFactory CreateSut() + { + return new BackOfficeClaimsPrincipalFactory(_mockUserManager.Object, + new OptionsWrapper(new IdentityOptions())); + } + } +} diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index 9815c94728..1c2d0ba0da 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -148,6 +148,7 @@ + diff --git a/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs b/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs index 2a90650d97..faefbce1cf 100644 --- a/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs +++ b/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; +using Umbraco.Core; using Umbraco.Core.Security; using Umbraco.Web.Models.Identity; @@ -19,10 +21,11 @@ namespace Umbraco.Web.Security public override async Task CreateAsync(TUser user) { - var claimsPrincipal = await base.CreateAsync(user); - + var baseIdentity = await base.GenerateClaimsAsync(user); + baseIdentity.AddClaim(new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", "ASP.NET Identity")); + var umbracoIdentity = new UmbracoBackOfficeIdentity( - claimsPrincipal.Identity as ClaimsIdentity, + baseIdentity, user.Id, user.UserName, user.Name, diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs index af83982647..4ec21e59a5 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Owin.Security.DataProtection; +using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.Mapping; using Umbraco.Core.Security; @@ -293,7 +294,10 @@ namespace Umbraco.Web.Security /// public async Task ChangePasswordWithResetAsync(int userId, string token, string newPassword) { - var user = await base.FindByIdAsync(userId.ToString()); + var userIdAsString = userId.TryConvertTo(); + if (!userIdAsString.Success) throw new InvalidOperationException("Unable to convert userId to int"); + + var user = await base.FindByIdAsync(userIdAsString.Result); var result = await base.ResetPasswordAsync(user, token, newPassword); if (result.Succeeded) RaisePasswordChangedEvent(userId); return result; diff --git a/src/Umbraco.Web/Security/BackOfficeUserStore2.cs b/src/Umbraco.Web/Security/BackOfficeUserStore2.cs index d5ed266f4f..7cad8d6726 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserStore2.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserStore2.cs @@ -66,12 +66,11 @@ namespace Umbraco.Web.Security public Task GetUserIdAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - - return Task.FromResult(user.Id.ToString()); + + return Task.FromResult(UserIdToString(user.Id)); } public Task GetUserNameAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) @@ -156,14 +155,8 @@ namespace Umbraco.Web.Security cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(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); + + var found = _userService.GetUserById(user.Id); if (found != null) { // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. @@ -194,19 +187,13 @@ namespace Umbraco.Web.Security cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(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); + + var found = _userService.GetUserById(user.Id); if (found != null) { _userService.Delete(found); } - _externalLoginService.DeleteUserLogins(asInt.Result); + _externalLoginService.DeleteUserLogins(user.Id); return Task.FromResult(IdentityResult.Success); } @@ -222,10 +209,7 @@ namespace Umbraco.Web.Security cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - var asInt = userId.TryConvertTo(); - if (asInt == false) throw new InvalidOperationException("The user id must be an integer to work with the Umbraco"); - - var user = _userService.GetUserById(asInt.Result); + var user = _userService.GetUserById(UserIdToInt(userId)); if (user == null) return null; return await Task.FromResult(AssignLoginsCallback(_mapper.Map(user))); @@ -312,7 +296,7 @@ namespace Umbraco.Web.Security cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - if (email.IsNullOrWhiteSpace()) throw new ArgumentNullException("email"); + // if (email.IsNullOrWhiteSpace()) throw new ArgumentNullException("email"); user.Email = email; @@ -898,8 +882,7 @@ namespace Umbraco.Web.Security private void ThrowIfDisposed() { - if (_disposed) - throw new ObjectDisposedException(GetType().Name); + if (_disposed) throw new ObjectDisposedException(GetType().Name); } public Task ValidateSessionIdAsync(string userId, string sessionId) @@ -907,10 +890,26 @@ namespace Umbraco.Web.Security Guid guidSessionId; if (Guid.TryParse(sessionId, out guidSessionId)) { - // TODO: SB: Normalize string to int conversion - return Task.FromResult(_userService.ValidateLoginSession(int.Parse(sessionId), guidSessionId)); + return Task.FromResult(_userService.ValidateLoginSession(UserIdToInt(userId), guidSessionId)); } + return Task.FromResult(false); } + + private string UserIdToString(int userId) + { + var attempt = userId.TryConvertTo(); + if (attempt.Success) return attempt.Result; + + throw new InvalidOperationException("Unable to convert user ID to string", attempt.Exception); + } + + private int UserIdToInt(string userId) + { + var attempt = userId.TryConvertTo(); + if (attempt.Success) return attempt.Result; + + throw new InvalidOperationException("Unable to convert user ID to int", attempt.Exception); + } } } From 809ead4223063554ec204a6d23a52ab46bf09fd7 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Sat, 4 Apr 2020 12:59:29 +0100 Subject: [PATCH 10/34] Removed temporary class naming --- .../BackOfficeUserAuditEventsComponent.cs | 22 +++++++-------- .../Editors/AuthenticationController.cs | 12 ++++---- .../Editors/BackOfficeController.cs | 8 +++--- .../Editors/CurrentUserController.cs | 2 +- src/Umbraco.Web/Editors/PasswordChanger.cs | 2 +- .../Install/InstallSteps/NewInstallStep.cs | 2 +- src/Umbraco.Web/OwinExtensions.cs | 14 +++++----- .../Security/AppBuilderExtensions.cs | 28 +++++++++---------- ...Manager2.cs => BackOfficeSignInManager.cs} | 18 ++++++------ ...erManager2.cs => BackOfficeUserManager.cs} | 22 +++++++-------- .../Security/BackOfficeUserManagerMarker.cs | 10 +++---- ...ceUserStore2.cs => BackOfficeUserStore.cs} | 6 ++-- ...lidator2.cs => BackOfficeUserValidator.cs} | 2 +- .../Security/IBackOfficeUserManagerMarker.cs | 12 +------- src/Umbraco.Web/Security/IUserSessionStore.cs | 2 +- .../Security/SessionIdValidator.cs | 2 +- src/Umbraco.Web/Security/WebSecurity.cs | 14 +++++----- src/Umbraco.Web/Umbraco.Web.csproj | 8 +++--- .../CheckIfUserTicketDataIsStaleAttribute.cs | 2 +- .../WebApi/UmbracoAuthorizedApiController.cs | 6 ++-- 20 files changed, 92 insertions(+), 102 deletions(-) rename src/Umbraco.Web/Security/{BackOfficeSignInManager2.cs => BackOfficeSignInManager.cs} (96%) rename src/Umbraco.Web/Security/{BackOfficeUserManager2.cs => BackOfficeUserManager.cs} (97%) rename src/Umbraco.Web/Security/{BackOfficeUserStore2.cs => BackOfficeUserStore.cs} (99%) rename src/Umbraco.Web/Security/{BackOfficeUserValidator2.cs => BackOfficeUserValidator.cs} (90%) diff --git a/src/Umbraco.Web/Compose/BackOfficeUserAuditEventsComponent.cs b/src/Umbraco.Web/Compose/BackOfficeUserAuditEventsComponent.cs index 7efa803c52..84fb0e6bb8 100644 --- a/src/Umbraco.Web/Compose/BackOfficeUserAuditEventsComponent.cs +++ b/src/Umbraco.Web/Compose/BackOfficeUserAuditEventsComponent.cs @@ -24,17 +24,17 @@ namespace Umbraco.Web.Compose public void Initialize() { - //BackOfficeUserManager2.AccountLocked += ; - //BackOfficeUserManager2.AccountUnlocked += ; - BackOfficeUserManager2.ForgotPasswordRequested += OnForgotPasswordRequest; - BackOfficeUserManager2.ForgotPasswordChangedSuccess += OnForgotPasswordChange; - BackOfficeUserManager2.LoginFailed += OnLoginFailed; - //BackOfficeUserManager2.LoginRequiresVerification += ; - BackOfficeUserManager2.LoginSuccess += OnLoginSuccess; - BackOfficeUserManager2.LogoutSuccess += OnLogoutSuccess; - BackOfficeUserManager2.PasswordChanged += OnPasswordChanged; - BackOfficeUserManager2.PasswordReset += OnPasswordReset; - //BackOfficeUserManager2.ResetAccessFailedCount += ; + //BackOfficeUserManager.AccountLocked += ; + //BackOfficeUserManager.AccountUnlocked += ; + BackOfficeUserManager.ForgotPasswordRequested += OnForgotPasswordRequest; + BackOfficeUserManager.ForgotPasswordChangedSuccess += OnForgotPasswordChange; + BackOfficeUserManager.LoginFailed += OnLoginFailed; + //BackOfficeUserManager.LoginRequiresVerification += ; + BackOfficeUserManager.LoginSuccess += OnLoginSuccess; + BackOfficeUserManager.LogoutSuccess += OnLogoutSuccess; + BackOfficeUserManager.PasswordChanged += OnPasswordChanged; + BackOfficeUserManager.PasswordReset += OnPasswordReset; + //BackOfficeUserManager.ResetAccessFailedCount += ; } public void Terminate() diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index 99be05c861..4e111a2dad 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -40,8 +40,8 @@ namespace Umbraco.Web.Editors [IsBackOffice] public class AuthenticationController : UmbracoApiController { - private BackOfficeUserManager2 _userManager; - private BackOfficeSignInManager2 _signInManager; + private BackOfficeUserManager _userManager; + private BackOfficeSignInManager _signInManager; private readonly IUserPasswordConfiguration _passwordConfiguration; private readonly IRuntimeState _runtimeState; private readonly IUmbracoSettingsSection _umbracoSettingsSection; @@ -69,11 +69,11 @@ namespace Umbraco.Web.Editors _ioHelper = ioHelper ?? throw new ArgumentNullException(nameof(ioHelper)); } - protected BackOfficeUserManager2 UserManager => _userManager - ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager2()); + protected BackOfficeUserManager UserManager => _userManager + ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager()); - protected BackOfficeSignInManager2 SignInManager => _signInManager - ?? (_signInManager = TryGetOwinContext().Result.GetBackOfficeSignInManager2()); + protected BackOfficeSignInManager SignInManager => _signInManager + ?? (_signInManager = TryGetOwinContext().Result.GetBackOfficeSignInManager()); /// /// Returns the configuration for the backoffice user membership provider - used to configure the change password dialog diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index 85cb9e91c8..ec26847839 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -43,8 +43,8 @@ namespace Umbraco.Web.Editors private readonly IManifestParser _manifestParser; private readonly UmbracoFeatures _features; private readonly IRuntimeState _runtimeState; - private BackOfficeUserManager2 _userManager; - private BackOfficeSignInManager2 _signInManager; + private BackOfficeUserManager _userManager; + private BackOfficeSignInManager _signInManager; private readonly IUmbracoVersion _umbracoVersion; private readonly IGridConfig _gridConfig; private readonly IUmbracoSettingsSection _umbracoSettingsSection; @@ -84,9 +84,9 @@ namespace Umbraco.Web.Editors _httpContextAccessor = httpContextAccessor; } - protected BackOfficeSignInManager2 SignInManager => _signInManager ?? (_signInManager = OwinContext.GetBackOfficeSignInManager2()); + protected BackOfficeSignInManager SignInManager => _signInManager ?? (_signInManager = OwinContext.GetBackOfficeSignInManager()); - protected BackOfficeUserManager2 UserManager => _userManager ?? (_userManager = OwinContext.GetBackOfficeUserManager2()); + protected BackOfficeUserManager UserManager => _userManager ?? (_userManager = OwinContext.GetBackOfficeUserManager()); protected IAuthenticationManager AuthenticationManager => OwinContext.Authentication; diff --git a/src/Umbraco.Web/Editors/CurrentUserController.cs b/src/Umbraco.Web/Editors/CurrentUserController.cs index cbee8f6e2d..35ec09eaca 100644 --- a/src/Umbraco.Web/Editors/CurrentUserController.cs +++ b/src/Umbraco.Web/Editors/CurrentUserController.cs @@ -211,7 +211,7 @@ namespace Umbraco.Web.Editors if (passwordChangeResult.Success) { - var userMgr = this.TryGetOwinContext().Result.GetBackOfficeUserManager2(); + var userMgr = this.TryGetOwinContext().Result.GetBackOfficeUserManager(); //even if we weren't resetting this, it is the correct value (null), otherwise if we were resetting then it will contain the new pword var result = new ModelWithNotifications(passwordChangeResult.Result.ResetPassword); diff --git a/src/Umbraco.Web/Editors/PasswordChanger.cs b/src/Umbraco.Web/Editors/PasswordChanger.cs index a6f47efd73..867856f494 100644 --- a/src/Umbraco.Web/Editors/PasswordChanger.cs +++ b/src/Umbraco.Web/Editors/PasswordChanger.cs @@ -34,7 +34,7 @@ namespace Umbraco.Web.Editors IUser currentUser, IUser savingUser, ChangingPasswordModel passwordModel, - BackOfficeUserManager2 userMgr) + BackOfficeUserManager userMgr) { if (passwordModel == null) throw new ArgumentNullException(nameof(passwordModel)); if (userMgr == null) throw new ArgumentNullException(nameof(userMgr)); diff --git a/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs b/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs index 6cc77a369f..c2bb8c95c2 100644 --- a/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs +++ b/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs @@ -56,7 +56,7 @@ namespace Umbraco.Web.Install.InstallSteps throw new InvalidOperationException("Could not find the super user!"); } - var userManager = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().GetBackOfficeUserManager2(); + var userManager = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().GetBackOfficeUserManager(); var membershipUser = await userManager.FindByIdAsync(Constants.Security.SuperUserId.ToString()); if (membershipUser == null) { diff --git a/src/Umbraco.Web/OwinExtensions.cs b/src/Umbraco.Web/OwinExtensions.cs index c27e466a7e..6905a5373a 100644 --- a/src/Umbraco.Web/OwinExtensions.cs +++ b/src/Umbraco.Web/OwinExtensions.cs @@ -57,10 +57,10 @@ namespace Umbraco.Web /// /// /// - public static BackOfficeSignInManager2 GetBackOfficeSignInManager2(this IOwinContext owinContext) + public static BackOfficeSignInManager GetBackOfficeSignInManager(this IOwinContext owinContext) { - return owinContext.Get() - ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager2)} from the {typeof(IOwinContext)}."); + return owinContext.Get() + ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager)} from the {typeof(IOwinContext)}."); } /// @@ -72,13 +72,13 @@ namespace Umbraco.Web /// This is required because to extract the user manager we need to user a custom service since owin only deals in generics and /// developers could register their own user manager types /// - public static BackOfficeUserManager2 GetBackOfficeUserManager2(this IOwinContext owinContext) + public static BackOfficeUserManager GetBackOfficeUserManager(this IOwinContext owinContext) { - var marker = owinContext.Get(BackOfficeUserManager2.OwinMarkerKey) - ?? throw new NullReferenceException($"No {typeof (IBackOfficeUserManagerMarker2)}, i.e. no Umbraco back-office, has been registered with Owin."); + var marker = owinContext.Get(BackOfficeUserManager.OwinMarkerKey) + ?? throw new NullReferenceException($"No {typeof (IBackOfficeUserManagerMarker)}, i.e. no Umbraco back-office, has been registered with Owin."); return marker.GetManager(owinContext) - ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager2)} from the {typeof (IOwinContext)}."); + ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager)} from the {typeof (IOwinContext)}."); } /// diff --git a/src/Umbraco.Web/Security/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/AppBuilderExtensions.cs index 1222d10971..2c8129a521 100644 --- a/src/Umbraco.Web/Security/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/AppBuilderExtensions.cs @@ -42,8 +42,8 @@ namespace Umbraco.Web.Security if (services == null) throw new ArgumentNullException(nameof(services)); //Configure Umbraco user manager to be created per request - app.CreatePerOwinContext( - (options, owinContext) => BackOfficeUserManager2.Create( + app.CreatePerOwinContext( + (options, owinContext) => BackOfficeUserManager.Create( services.UserService, services.EntityService, services.ExternalLoginService, @@ -53,12 +53,12 @@ namespace Umbraco.Web.Security ipResolver, new IdentityErrorDescriber(), app.GetDataProtectionProvider(), - new NullLogger>())); + new NullLogger>())); - app.SetBackOfficeUserManagerType(); + app.SetBackOfficeUserManagerType(); //Create a sign in manager per request - app.CreatePerOwinContext((options, context) => BackOfficeSignInManager2.Create(context, globalSettings, app.CreateLogger())); + app.CreatePerOwinContext((options, context) => BackOfficeSignInManager.Create(context, globalSettings, app.CreateLogger())); } /// @@ -67,7 +67,7 @@ namespace Umbraco.Web.Security public static void ConfigureUserManagerForUmbracoBackOffice(this IAppBuilder app, IRuntimeState runtimeState, IGlobalSettings globalSettings, - BackOfficeUserStore2 customUserStore, + BackOfficeUserStore customUserStore, // TODO: This could probably be optional? IPasswordConfiguration passwordConfiguration, IIpResolver ipResolver) @@ -76,19 +76,19 @@ namespace Umbraco.Web.Security if (customUserStore == null) throw new ArgumentNullException(nameof(customUserStore)); //Configure Umbraco user manager to be created per request - app.CreatePerOwinContext( - (options, owinContext) => BackOfficeUserManager2.Create( + app.CreatePerOwinContext( + (options, owinContext) => BackOfficeUserManager.Create( passwordConfiguration, ipResolver, customUserStore, new IdentityErrorDescriber(), app.GetDataProtectionProvider(), - new NullLogger>())); + new NullLogger>())); - app.SetBackOfficeUserManagerType(); + app.SetBackOfficeUserManagerType(); //Create a sign in manager per request - app.CreatePerOwinContext((options, context) => BackOfficeSignInManager2.Create(context, globalSettings, app.CreateLogger(typeof(BackOfficeSignInManager2).FullName))); + app.CreatePerOwinContext((options, context) => BackOfficeSignInManager.Create(context, globalSettings, app.CreateLogger(typeof(BackOfficeSignInManager).FullName))); } /// @@ -152,7 +152,7 @@ namespace Umbraco.Web.Security // logs in. This is a security feature which is used when you // change a password or add an external login to your account. /*OnValidateIdentity = SecurityStampValidator - .OnValidateIdentity( + .OnValidateIdentity( TimeSpan.FromMinutes(30), (manager, user) => manager.GenerateUserIdentityAsync(user), identity => identity.GetUserId()),*/ @@ -226,7 +226,7 @@ namespace Umbraco.Web.Security /// differently in the owin context /// private static void SetBackOfficeUserManagerType(this IAppBuilder app) - where TManager : BackOfficeUserManager2 + where TManager : BackOfficeUserManager where TUser : BackOfficeIdentityUser { if (_markerSet) throw new InvalidOperationException("The back office user manager marker has already been set, only one back office user manager can be configured"); @@ -236,7 +236,7 @@ namespace Umbraco.Web.Security // a generic strongly typed instance app.Use((context, func) => { - context.Set(BackOfficeUserManager2.OwinMarkerKey, new BackOfficeUserManagerMarker2()); + context.Set(BackOfficeUserManager.OwinMarkerKey, new BackOfficeUserManagerMarker()); return func(); }); } diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs similarity index 96% rename from src/Umbraco.Web/Security/BackOfficeSignInManager2.cs rename to src/Umbraco.Web/Security/BackOfficeSignInManager.cs index 1f2d4f0bb9..f2f992e0f1 100644 --- a/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs +++ b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs @@ -20,17 +20,17 @@ namespace Umbraco.Web.Security /// Code ported from Umbraco's BackOfficeSignInManager. /// Can be removed once the web project moves to .NET Core. /// - public class BackOfficeSignInManager2 : IDisposable + public class BackOfficeSignInManager : IDisposable { - private readonly BackOfficeUserManager2 _userManager; + private readonly BackOfficeUserManager _userManager; private readonly IUserClaimsPrincipalFactory _claimsPrincipalFactory; private readonly IAuthenticationManager _authenticationManager; private readonly ILogger _logger; private readonly IGlobalSettings _globalSettings; private readonly IOwinRequest _request; - public BackOfficeSignInManager2( - BackOfficeUserManager2 userManager, + public BackOfficeSignInManager( + BackOfficeUserManager userManager, IUserClaimsPrincipalFactory claimsPrincipalFactory, IAuthenticationManager authenticationManager, ILogger logger, @@ -53,11 +53,11 @@ namespace Umbraco.Web.Security return claimsPrincipal.Identity as ClaimsIdentity; } - public static BackOfficeSignInManager2 Create(IOwinContext context, IGlobalSettings globalSettings, ILogger logger) + public static BackOfficeSignInManager Create(IOwinContext context, IGlobalSettings globalSettings, ILogger logger) { - var userManager = context.GetBackOfficeUserManager2(); + var userManager = context.GetBackOfficeUserManager(); - return new BackOfficeSignInManager2( + return new BackOfficeSignInManager( userManager, new BackOfficeClaimsPrincipalFactory(userManager, new OptionsWrapper(userManager.Options)), context.Authentication, @@ -157,7 +157,7 @@ namespace Umbraco.Web.Security if (requestContext != null) { - var backofficeUserManager = requestContext.GetBackOfficeUserManager2(); + var backofficeUserManager = requestContext.GetBackOfficeUserManager(); if (backofficeUserManager != null) backofficeUserManager.RaiseAccountLockedEvent(user.Id); } @@ -167,7 +167,7 @@ namespace Umbraco.Web.Security if (requestContext != null) { - var backofficeUserManager = requestContext.GetBackOfficeUserManager2(); + var backofficeUserManager = requestContext.GetBackOfficeUserManager(); if (backofficeUserManager != null) backofficeUserManager.RaiseInvalidLoginAttemptEvent(userName); } diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs b/src/Umbraco.Web/Security/BackOfficeUserManager.cs similarity index 97% rename from src/Umbraco.Web/Security/BackOfficeUserManager2.cs rename to src/Umbraco.Web/Security/BackOfficeUserManager.cs index 4ec21e59a5..286d7ba1a7 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager.cs @@ -16,11 +16,11 @@ using Umbraco.Web.Models.Identity; namespace Umbraco.Web.Security { - public class BackOfficeUserManager2 : BackOfficeUserManager2 + public class BackOfficeUserManager : BackOfficeUserManager { public const string OwinMarkerKey = "Umbraco.Web.Security.Identity.BackOfficeUserManagerMarker"; - public BackOfficeUserManager2( + public BackOfficeUserManager( IPasswordConfiguration passwordConfiguration, IIpResolver ipResolver, IUserStore store, @@ -41,7 +41,7 @@ namespace Umbraco.Web.Security /// /// Creates a BackOfficeUserManager instance with all default options and the default BackOfficeUserManager /// - public static BackOfficeUserManager2 Create( + public static BackOfficeUserManager Create( IUserService userService, IEntityService entityService, IExternalLoginService externalLoginService, @@ -53,7 +53,7 @@ namespace Umbraco.Web.Security IDataProtectionProvider dataProtectionProvider, ILogger> logger) { - var store = new BackOfficeUserStore2(userService, entityService, externalLoginService, globalSettings, mapper); + var store = new BackOfficeUserStore(userService, entityService, externalLoginService, globalSettings, mapper); return Create( passwordConfiguration, @@ -67,7 +67,7 @@ namespace Umbraco.Web.Security /// /// Creates a BackOfficeUserManager instance with all default options and a custom BackOfficeUserManager instance /// - public static BackOfficeUserManager2 Create( + public static BackOfficeUserManager Create( IPasswordConfiguration passwordConfiguration, IIpResolver ipResolver, IUserStore customUserStore, @@ -78,7 +78,7 @@ namespace Umbraco.Web.Security var options = new IdentityOptions(); // Configure validation logic for usernames - var userValidators = new List> { new BackOfficeUserValidator2() }; + var userValidators = new List> { new BackOfficeUserValidator() }; options.User.RequireUniqueEmail = true; // Configure validation logic for passwords @@ -96,7 +96,7 @@ namespace Umbraco.Web.Security // locked out or not. options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(30); - return new BackOfficeUserManager2( + return new BackOfficeUserManager( passwordConfiguration, ipResolver, customUserStore, @@ -112,12 +112,12 @@ namespace Umbraco.Web.Security #endregion } - public class BackOfficeUserManager2 : UserManager + public class BackOfficeUserManager : UserManager where T : BackOfficeIdentityUser { private PasswordGenerator _passwordGenerator; - public BackOfficeUserManager2( + public BackOfficeUserManager( IPasswordConfiguration passwordConfiguration, IIpResolver ipResolver, IUserStore store, @@ -154,7 +154,7 @@ namespace Umbraco.Web.Security /// Initializes the user manager with the correct options /// protected void InitUserManager( - BackOfficeUserManager2 manager, + BackOfficeUserManager manager, IDataProtectionProvider dataProtectionProvider) { //use a custom hasher based on our membership provider @@ -181,7 +181,7 @@ namespace Umbraco.Web.Security /// public virtual async Task ValidateSessionIdAsync(string userId, string sessionId) { - var userSessionStore = Store as IUserSessionStore2; + var userSessionStore = Store as IUserSessionStore; //if this is not set, for backwards compat (which would be super rare), we'll just approve it if (userSessionStore == null) return true; diff --git a/src/Umbraco.Web/Security/BackOfficeUserManagerMarker.cs b/src/Umbraco.Web/Security/BackOfficeUserManagerMarker.cs index d9f430c84f..b3eb9da3d5 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManagerMarker.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManagerMarker.cs @@ -11,14 +11,14 @@ namespace Umbraco.Web.Security /// /// /// - internal class BackOfficeUserManagerMarker2 : IBackOfficeUserManagerMarker2 - where TManager : BackOfficeUserManager2 + internal class BackOfficeUserManagerMarker : IBackOfficeUserManagerMarker + where TManager : BackOfficeUserManager where TUser : BackOfficeIdentityUser { - public BackOfficeUserManager2 GetManager(IOwinContext owin) + public BackOfficeUserManager GetManager(IOwinContext owin) { - var mgr = owin.Get() as BackOfficeUserManager2; - if (mgr == null) throw new InvalidOperationException("Could not cast the registered back office user of type " + typeof(TManager) + " to " + typeof(BackOfficeUserManager2)); + var mgr = owin.Get() as BackOfficeUserManager; + if (mgr == null) throw new InvalidOperationException("Could not cast the registered back office user of type " + typeof(TManager) + " to " + typeof(BackOfficeUserManager)); return mgr; } } diff --git a/src/Umbraco.Web/Security/BackOfficeUserStore2.cs b/src/Umbraco.Web/Security/BackOfficeUserStore.cs similarity index 99% rename from src/Umbraco.Web/Security/BackOfficeUserStore2.cs rename to src/Umbraco.Web/Security/BackOfficeUserStore.cs index 7cad8d6726..6ddae55d82 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserStore2.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserStore.cs @@ -20,7 +20,7 @@ using UserLoginInfo = Microsoft.AspNetCore.Identity.UserLoginInfo; namespace Umbraco.Web.Security { - public class BackOfficeUserStore2 : DisposableObjectSlim, + public class BackOfficeUserStore : DisposableObjectSlim, IUserPasswordStore, IUserEmailStore, IUserLoginStore, @@ -28,7 +28,7 @@ namespace Umbraco.Web.Security IUserSecurityStampStore, IUserLockoutStore, IUserTwoFactorStore, - IUserSessionStore2 + IUserSessionStore // TODO: This would require additional columns/tables for now people will need to implement this on their own //IUserPhoneNumberStore, @@ -42,7 +42,7 @@ namespace Umbraco.Web.Security private readonly UmbracoMapper _mapper; private bool _disposed = false; - public BackOfficeUserStore2(IUserService userService, IEntityService entityService, IExternalLoginService externalLoginService, IGlobalSettings globalSettings, UmbracoMapper mapper) + public BackOfficeUserStore(IUserService userService, IEntityService entityService, IExternalLoginService externalLoginService, IGlobalSettings globalSettings, UmbracoMapper mapper) { _userService = userService; _entityService = entityService; diff --git a/src/Umbraco.Web/Security/BackOfficeUserValidator2.cs b/src/Umbraco.Web/Security/BackOfficeUserValidator.cs similarity index 90% rename from src/Umbraco.Web/Security/BackOfficeUserValidator2.cs rename to src/Umbraco.Web/Security/BackOfficeUserValidator.cs index b6438d730d..94e9c2e0bd 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserValidator2.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserValidator.cs @@ -4,7 +4,7 @@ using Umbraco.Web.Models.Identity; namespace Umbraco.Web.Security { - public class BackOfficeUserValidator2 : UserValidator + public class BackOfficeUserValidator : UserValidator where T : BackOfficeIdentityUser { public override async Task ValidateAsync(UserManager manager, T user) diff --git a/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs b/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs index 93b51eec21..bfe2590eb8 100644 --- a/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs +++ b/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs @@ -1,5 +1,4 @@ using Microsoft.Owin; -using Umbraco.Core.Models.Identity; using Umbraco.Web.Models.Identity; namespace Umbraco.Web.Security @@ -11,15 +10,6 @@ namespace Umbraco.Web.Security /// internal interface IBackOfficeUserManagerMarker { - BackOfficeUserManager2 GetManager(IOwinContext owin); - } - /// - /// This interface is only here due to the fact that IOwinContext Get / Set only work in generics, if they worked - /// with regular 'object' then we wouldn't have to use this work around but because of that we have to use this - /// class to resolve the 'real' type of the registered user manager - /// - internal interface IBackOfficeUserManagerMarker2 - { - BackOfficeUserManager2 GetManager(IOwinContext owin); + BackOfficeUserManager GetManager(IOwinContext owin); } } diff --git a/src/Umbraco.Web/Security/IUserSessionStore.cs b/src/Umbraco.Web/Security/IUserSessionStore.cs index d410504ef7..b7575475d3 100644 --- a/src/Umbraco.Web/Security/IUserSessionStore.cs +++ b/src/Umbraco.Web/Security/IUserSessionStore.cs @@ -7,7 +7,7 @@ namespace Umbraco.Core.Security /// An IUserStore interface part to implement if the store supports validating user session Ids /// /// - public interface IUserSessionStore2 : IUserStore + public interface IUserSessionStore : IUserStore where TUser : class { Task ValidateSessionIdAsync(string userId, string sessionId); // TODO: SB: Should take a user??? diff --git a/src/Umbraco.Web/Security/SessionIdValidator.cs b/src/Umbraco.Web/Security/SessionIdValidator.cs index 2e2afa8da5..bd9c96a5b9 100644 --- a/src/Umbraco.Web/Security/SessionIdValidator.cs +++ b/src/Umbraco.Web/Security/SessionIdValidator.cs @@ -85,7 +85,7 @@ namespace Umbraco.Web.Security if (validate == false) return true; - var manager = owinCtx.Get(); + var manager = owinCtx.Get(); if (manager == null) return false; diff --git a/src/Umbraco.Web/Security/WebSecurity.cs b/src/Umbraco.Web/Security/WebSecurity.cs index 08a2626cb3..fa29739e91 100644 --- a/src/Umbraco.Web/Security/WebSecurity.cs +++ b/src/Umbraco.Web/Security/WebSecurity.cs @@ -52,17 +52,17 @@ namespace Umbraco.Web.Security } } - private BackOfficeSignInManager2 _signInManager; - private BackOfficeSignInManager2 SignInManager + private BackOfficeSignInManager _signInManager; + private BackOfficeSignInManager SignInManager { get { if (_signInManager == null) { - var mgr = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().Get(); + var mgr = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().Get(); if (mgr == null) { - throw new NullReferenceException("Could not resolve an instance of " + typeof(BackOfficeSignInManager2) + " from the " + typeof(IOwinContext)); + throw new NullReferenceException("Could not resolve an instance of " + typeof(BackOfficeSignInManager) + " from the " + typeof(IOwinContext)); } _signInManager = mgr; } @@ -70,9 +70,9 @@ namespace Umbraco.Web.Security } } - private BackOfficeUserManager2 _userManager; - protected BackOfficeUserManager2 UserManager - => _userManager ?? (_userManager = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().GetBackOfficeUserManager2()); + private BackOfficeUserManager _userManager; + protected BackOfficeUserManager UserManager + => _userManager ?? (_userManager = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().GetBackOfficeUserManager()); [Obsolete("This needs to be removed, ASP.NET Identity should always be used for this operation, this is currently only used in the installer which needs to be updated")] public double PerformLogin(int userId) diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index a53187f46b..703a52ccdc 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -199,11 +199,11 @@ - - + + - - + + diff --git a/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs b/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs index 2e29b44200..c68b949bba 100644 --- a/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/CheckIfUserTicketDataIsStaleAttribute.cs @@ -113,7 +113,7 @@ namespace Umbraco.Web.WebApi.Filters var owinCtx = actionContext.Request.TryGetOwinContext(); if (owinCtx) { - var signInManager = owinCtx.Result.GetBackOfficeSignInManager2(); + var signInManager = owinCtx.Result.GetBackOfficeSignInManager(); var backOfficeIdentityUser = Mapper.Map(user); await signInManager.SignInAsync(backOfficeIdentityUser, isPersistent: true, rememberBrowser: false); diff --git a/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs b/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs index 985343946e..b289e62e9a 100644 --- a/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs +++ b/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs @@ -30,7 +30,7 @@ namespace Umbraco.Web.WebApi [EnableDetailedErrors] public abstract class UmbracoAuthorizedApiController : UmbracoApiController { - private BackOfficeUserManager2 _userManager; + private BackOfficeUserManager _userManager; protected UmbracoAuthorizedApiController() { @@ -44,7 +44,7 @@ namespace Umbraco.Web.WebApi /// /// Gets the user manager. /// - protected BackOfficeUserManager2 UserManager - => _userManager ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager2()); + protected BackOfficeUserManager UserManager + => _userManager ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager()); } } From 02dfa893e9b6b98dac61c146b92dd11c5880d274 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Sat, 4 Apr 2020 19:58:09 +0100 Subject: [PATCH 11/34] Cleanup --- .../BackOfficeClaimsPrincipalFactoryTests.cs | 119 +++++++++++++++++- .../BackOfficeClaimsPrincipalFactory.cs | 11 +- .../Security/BackOfficeUserManager.cs | 7 ++ src/Umbraco.Web/Security/IUserSessionStore.cs | 2 +- .../OwinDataProtectorTokenProvider.cs | 3 +- 5 files changed, 131 insertions(+), 11 deletions(-) diff --git a/src/Umbraco.Tests/Security/BackOfficeClaimsPrincipalFactoryTests.cs b/src/Umbraco.Tests/Security/BackOfficeClaimsPrincipalFactoryTests.cs index 6f55381a62..b7b516318c 100644 --- a/src/Umbraco.Tests/Security/BackOfficeClaimsPrincipalFactoryTests.cs +++ b/src/Umbraco.Tests/Security/BackOfficeClaimsPrincipalFactoryTests.cs @@ -1,11 +1,12 @@ -using System.Collections.Generic; -using System.Linq; +using System; +using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; +using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.Models.Membership; using Umbraco.Core.Security; @@ -20,6 +21,40 @@ namespace Umbraco.Tests.Security private BackOfficeIdentityUser _testUser; private Mock> _mockUserManager; + [Test] + public void Ctor_When_UserManager_Is_Null_Expect_ArgumentNullException() + { + Assert.Throws(() => new BackOfficeClaimsPrincipalFactory( + null, + new OptionsWrapper(new IdentityOptions()))); + } + + [Test] + public void Ctor_When_Options_Are_Null_Expect_ArgumentNullException() + { + Assert.Throws(() => new BackOfficeClaimsPrincipalFactory( + new Mock>(new Mock>().Object, + null, null, null, null, null, null, null, null).Object, + null)); + } + + [Test] + public void Ctor_When_Options_Value_Is_Null_Expect_ArgumentNullException() + { + Assert.Throws(() => new BackOfficeClaimsPrincipalFactory( + new Mock>(new Mock>().Object, + null, null, null, null, null, null, null, null).Object, + new OptionsWrapper(null))); + } + + [Test] + public void CreateAsync_When_User_Is_Null_Expect_ArgumentNullException() + { + var sut = CreateSut(); + + Assert.ThrowsAsync(async () => await sut.CreateAsync(null)); + } + [Test] public async Task CreateAsync_Should_Create_Principal_With_Umbraco_Identity() { @@ -34,23 +69,95 @@ namespace Umbraco.Tests.Security [Test] public async Task CreateAsync_Should_Create_NameId() { + const string expectedClaimType = ClaimTypes.NameIdentifier; + var expectedClaimValue = _testUser.Id.ToString(); + var sut = CreateSut(); var claimsPrincipal = await sut.CreateAsync(_testUser); - Assert.True(claimsPrincipal.HasClaim(ClaimTypes.NameIdentifier, _testUser.Id.ToString())); + Assert.True(claimsPrincipal.HasClaim(expectedClaimType, expectedClaimValue)); + Assert.True(claimsPrincipal.GetUmbracoIdentity().Actor.HasClaim(expectedClaimType, expectedClaimValue)); + } + + [Test] + public async Task CreateAsync_Should_Create_Name() + { + const string expectedClaimType = ClaimTypes.Name; + var expectedClaimValue = _testUser.UserName; + + var sut = CreateSut(); + + var claimsPrincipal = await sut.CreateAsync(_testUser); + + Assert.True(claimsPrincipal.HasClaim(expectedClaimType, expectedClaimValue)); + Assert.True(claimsPrincipal.GetUmbracoIdentity().Actor.HasClaim(expectedClaimType, expectedClaimValue)); } [Test] public async Task CreateAsync_Should_Create_IdentityProvider() { + const string expectedClaimType = "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider"; + const string expectedClaimValue = "ASP.NET Identity"; + var sut = CreateSut(); var claimsPrincipal = await sut.CreateAsync(_testUser); - Assert.True(claimsPrincipal.HasClaim( - "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", - "ASP.NET Identity")); + Assert.True(claimsPrincipal.HasClaim(expectedClaimType, expectedClaimValue)); + Assert.True(claimsPrincipal.GetUmbracoIdentity().Actor.HasClaim(expectedClaimType, expectedClaimValue)); + } + + [Test] + public async Task CreateAsync_When_SecurityStamp_Supported_Expect_SecurityStamp_Claim() + { + const string expectedClaimType = Constants.Web.SecurityStampClaimType; + var expectedClaimValue = _testUser.SecurityStamp; + + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(true); + _mockUserManager.Setup(x => x.GetSecurityStampAsync(_testUser)).ReturnsAsync(_testUser.SecurityStamp); + + var sut = CreateSut(); + + var claimsPrincipal = await sut.CreateAsync(_testUser); + + Assert.True(claimsPrincipal.HasClaim(expectedClaimType, expectedClaimValue)); + Assert.True(claimsPrincipal.GetUmbracoIdentity().Actor.HasClaim(expectedClaimType, expectedClaimValue)); + } + + [Test] + public async Task CreateAsync_When_Roles_Supported_Expect_Role_Claims_In_UmbracoIdentity() + { + const string expectedClaimType = ClaimTypes.Role; + const string expectedClaimValue = "b87309fb-4caf-48dc-b45a-2b752d051508"; + + _testUser.Roles.Add(new Core.Models.Identity.IdentityUserRole{RoleId = expectedClaimValue}); + _mockUserManager.Setup(x => x.SupportsUserRole).Returns(true); + _mockUserManager.Setup(x => x.GetRolesAsync(_testUser)).ReturnsAsync(new[] {expectedClaimValue}); + + var sut = CreateSut(); + + var claimsPrincipal = await sut.CreateAsync(_testUser); + + Assert.True(claimsPrincipal.HasClaim(expectedClaimType, expectedClaimValue)); + } + + [Test] + public async Task CreateAsync_When_UserClaims_Supported_Expect_UserClaims_In_Actor() + { + const string expectedClaimType = "custom"; + const string expectedClaimValue = "val"; + + _testUser.Claims.Add(new Core.Models.Identity.IdentityUserClaim {ClaimType = expectedClaimType, ClaimValue = expectedClaimValue}); + _mockUserManager.Setup(x => x.SupportsUserClaim).Returns(true); + _mockUserManager.Setup(x => x.GetClaimsAsync(_testUser)).ReturnsAsync( + new List {new Claim(expectedClaimType, expectedClaimValue)}); + + var sut = CreateSut(); + + var claimsPrincipal = await sut.CreateAsync(_testUser); + + Assert.True(claimsPrincipal.GetUmbracoIdentity().Actor.HasClaim(expectedClaimType, expectedClaimValue)); } [SetUp] diff --git a/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs b/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs index faefbce1cf..fe22981831 100644 --- a/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs +++ b/src/Umbraco.Web/Security/BackOfficeClaimsPrincipalFactory.cs @@ -1,11 +1,9 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; -using Umbraco.Core; using Umbraco.Core.Security; using Umbraco.Web.Models.Identity; @@ -14,6 +12,9 @@ namespace Umbraco.Web.Security public class BackOfficeClaimsPrincipalFactory : UserClaimsPrincipalFactory where TUser : BackOfficeIdentityUser { + private const string _identityProviderClaimType = "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider"; + private const string _identityProviderClaimValue = "ASP.NET Identity"; + public BackOfficeClaimsPrincipalFactory(UserManager userManager, IOptions optionsAccessor) : base(userManager, optionsAccessor) { @@ -21,8 +22,12 @@ namespace Umbraco.Web.Security public override async Task CreateAsync(TUser user) { + if (user == null) throw new ArgumentNullException(nameof(user)); + var baseIdentity = await base.GenerateClaimsAsync(user); - baseIdentity.AddClaim(new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", "ASP.NET Identity")); + + // Required by ASP.NET 4.x anti-forgery implementation + baseIdentity.AddClaim(new Claim(_identityProviderClaimType, _identityProviderClaimValue)); var umbracoIdentity = new UmbracoBackOfficeIdentity( baseIdentity, diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager.cs b/src/Umbraco.Web/Security/BackOfficeUserManager.cs index 286d7ba1a7..b9d72ce0c1 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; @@ -89,6 +90,12 @@ namespace Umbraco.Web.Security options.Password.RequireLowercase = passwordConfiguration.RequireLowercase; options.Password.RequireUppercase = passwordConfiguration.RequireUppercase; + // Ensure Umbraco security stamp claim type is used + options.ClaimsIdentity.UserIdClaimType = ClaimTypes.NameIdentifier; + options.ClaimsIdentity.UserNameClaimType = ClaimTypes.Name; + options.ClaimsIdentity.RoleClaimType = ClaimTypes.Role; + options.ClaimsIdentity.SecurityStampClaimType = Constants.Web.SecurityStampClaimType; + options.Lockout.AllowedForNewUsers = true; options.Lockout.MaxFailedAccessAttempts = passwordConfiguration.MaxFailedAccessAttemptsBeforeLockout; //NOTE: This just needs to be in the future, we currently don't support a lockout timespan, it's either they are locked diff --git a/src/Umbraco.Web/Security/IUserSessionStore.cs b/src/Umbraco.Web/Security/IUserSessionStore.cs index b7575475d3..06b7c2f165 100644 --- a/src/Umbraco.Web/Security/IUserSessionStore.cs +++ b/src/Umbraco.Web/Security/IUserSessionStore.cs @@ -10,6 +10,6 @@ namespace Umbraco.Core.Security public interface IUserSessionStore : IUserStore where TUser : class { - Task ValidateSessionIdAsync(string userId, string sessionId); // TODO: SB: Should take a user??? + Task ValidateSessionIdAsync(string userId, string sessionId); } } diff --git a/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs b/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs index 4e90980478..51a42aae60 100644 --- a/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs +++ b/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs @@ -99,7 +99,8 @@ namespace Umbraco.Web.Security public Task CanGenerateTwoFactorTokenAsync(UserManager manager, TUser user) { - return Task.FromResult(true); + // This token provider is designed for flows such as password reset and account confirmation + return Task.FromResult(false); } } From e5f3c4a1863cb73f37be501ee1d18a46a27778df Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Mon, 6 Apr 2020 17:23:04 +0100 Subject: [PATCH 12/34] Security stamp validator. Removed more references to ASP.NET Identity 2 --- build/NuSpecs/UmbracoCms.Web.nuspec | 2 +- .../Models/Identity/IdentityUser.cs | 3 +- .../Security/AppBuilderExtensions.cs | 11 ++- .../Security/BackOfficeUserManager.cs | 4 +- src/Umbraco.Web/Security/EmailService.cs | 56 ------------ .../Security/UmbracoEmailMessage.cs | 17 ---- .../Security/UmbracoSecurityStampValidator.cs | 86 +++++++++++++++++++ ...dHasher2.cs => UserAwarePasswordHasher.cs} | 4 +- src/Umbraco.Web/Umbraco.Web.csproj | 7 +- 9 files changed, 100 insertions(+), 90 deletions(-) delete mode 100644 src/Umbraco.Web/Security/EmailService.cs delete mode 100644 src/Umbraco.Web/Security/UmbracoEmailMessage.cs create mode 100644 src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs rename src/Umbraco.Web/Security/{UserAwarePasswordHasher2.cs => UserAwarePasswordHasher.cs} (91%) diff --git a/build/NuSpecs/UmbracoCms.Web.nuspec b/build/NuSpecs/UmbracoCms.Web.nuspec index 7199f414b1..7630dad842 100644 --- a/build/NuSpecs/UmbracoCms.Web.nuspec +++ b/build/NuSpecs/UmbracoCms.Web.nuspec @@ -33,7 +33,7 @@ - + diff --git a/src/Umbraco.Web/Models/Identity/IdentityUser.cs b/src/Umbraco.Web/Models/Identity/IdentityUser.cs index df104fcafe..7bd077e879 100644 --- a/src/Umbraco.Web/Models/Identity/IdentityUser.cs +++ b/src/Umbraco.Web/Models/Identity/IdentityUser.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Microsoft.AspNet.Identity; using Umbraco.Core.Models.Identity; namespace Umbraco.Web.Models.Identity @@ -13,7 +12,7 @@ namespace Umbraco.Web.Models.Identity /// 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 + public class IdentityUser where TLogin : IIdentityUserLogin //NOTE: Making our role id a string where TRole : IdentityUserRole diff --git a/src/Umbraco.Web/Security/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/AppBuilderExtensions.cs index 2c8129a521..20105cabe8 100644 --- a/src/Umbraco.Web/Security/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/AppBuilderExtensions.cs @@ -147,15 +147,14 @@ namespace Umbraco.Web.Security authOptions.Provider = new BackOfficeCookieAuthenticationProvider(userService, runtimeState, globalSettings, ioHelper, umbracoSettingsSection) { - // TODO: SB: SecurityStampValidator // Enables the application to validate the security stamp when the user // logs in. This is a security feature which is used when you // change a password or add an external login to your account. - /*OnValidateIdentity = SecurityStampValidator - .OnValidateIdentity( - TimeSpan.FromMinutes(30), - (manager, user) => manager.GenerateUserIdentityAsync(user), - identity => identity.GetUserId()),*/ + OnValidateIdentity = UmbracoSecurityStampValidator + .OnValidateIdentity( + TimeSpan.FromMinutes(3), + (signInManager, manager, user) => signInManager.CreateUserIdentityAsync(user), + identity => identity.GetUserId()), }; diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager.cs b/src/Umbraco.Web/Security/BackOfficeUserManager.cs index b9d72ce0c1..ab69349afc 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager.cs @@ -89,7 +89,7 @@ namespace Umbraco.Web.Security options.Password.RequireDigit = passwordConfiguration.RequireDigit; options.Password.RequireLowercase = passwordConfiguration.RequireLowercase; options.Password.RequireUppercase = passwordConfiguration.RequireUppercase; - + // Ensure Umbraco security stamp claim type is used options.ClaimsIdentity.UserIdClaimType = ClaimTypes.NameIdentifier; options.ClaimsIdentity.UserNameClaimType = ClaimTypes.Name; @@ -202,7 +202,7 @@ namespace Umbraco.Web.Security protected virtual IPasswordHasher GetDefaultPasswordHasher(IPasswordConfiguration passwordConfiguration) { //we can use the user aware password hasher (which will be the default and preferred way) - return new UserAwarePasswordHasher2(new PasswordSecurity(passwordConfiguration)); + return new UserAwarePasswordHasher(new PasswordSecurity(passwordConfiguration)); } /// diff --git a/src/Umbraco.Web/Security/EmailService.cs b/src/Umbraco.Web/Security/EmailService.cs deleted file mode 100644 index e6454544ab..0000000000 --- a/src/Umbraco.Web/Security/EmailService.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.ComponentModel; -using System.Net.Mail; -using System.Threading.Tasks; -using Microsoft.AspNet.Identity; -using Umbraco.Core.Configuration; - -namespace Umbraco.Core.Security -{ - /// - /// The implementation for Umbraco - /// - public class EmailService : IIdentityMessageService - { - private readonly string _notificationEmailAddress; - private readonly IEmailSender _defaultEmailSender; - - public EmailService(string notificationEmailAddress, IEmailSender defaultEmailSender) - { - _notificationEmailAddress = notificationEmailAddress; - _defaultEmailSender = defaultEmailSender; - } - - - public async Task SendAsync(IdentityMessage message) - { - var mailMessage = new MailMessage( - _notificationEmailAddress, - message.Destination, - message.Subject, - message.Body) - { - IsBodyHtml = message.Body.IsNullOrWhiteSpace() == false - && message.Body.Contains("<") && message.Body.Contains(" - /// A custom implementation for IdentityMessage that allows the customization of how an email is sent - /// - internal class UmbracoEmailMessage : IdentityMessage - { - public IEmailSender MailSender { get; private set; } - - public UmbracoEmailMessage(IEmailSender mailSender) - { - MailSender = mailSender; - } - } -} diff --git a/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs b/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs new file mode 100644 index 0000000000..f506a64fba --- /dev/null +++ b/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs @@ -0,0 +1,86 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.Owin.Security.Cookies; +using Umbraco.Core; +using Umbraco.Web.Models.Identity; + +namespace Umbraco.Web.Security +{ + /// + /// Adapted from Microsoft.AspNet.Identity.Owin.SecurityStampValidator + /// + public class UmbracoSecurityStampValidator + { + public static Func OnValidateIdentity( + TimeSpan validateInterval, + Func> regenerateIdentityCallback, + Func getUserIdCallback) + where TManager : BackOfficeUserManager + where TUser : BackOfficeIdentityUser + { + if (getUserIdCallback == null) throw new ArgumentNullException(nameof(getUserIdCallback)); + + return async context => + { + var currentUtc = DateTimeOffset.UtcNow; + if (context.Options != null && context.Options.SystemClock != null) + { + currentUtc = context.Options.SystemClock.UtcNow; + } + + var issuedUtc = context.Properties.IssuedUtc; + + // Only validate if enough time has elapsed + var validate = issuedUtc == null; + if (issuedUtc != null) + { + var timeElapsed = currentUtc.Subtract(issuedUtc.Value); + validate = timeElapsed > validateInterval; + } + + if (validate) + { + var manager = context.OwinContext.Get(); + var signInManager = context.OwinContext.GetBackOfficeSignInManager(); + + var userId = getUserIdCallback(context.Identity); + + if (manager != null && userId != null) + { + var user = await manager.FindByIdAsync(userId); + var reject = true; + + // Refresh the identity if the stamp matches, otherwise reject + if (user != null && manager.SupportsUserSecurityStamp) + { + var securityStamp = context.Identity.FindFirst(Constants.Web.SecurityStampClaimType)?.Value; + if (securityStamp == await manager.GetSecurityStampAsync(user)) + { + reject = false; + // Regenerate fresh claims if possible and resign in + if (regenerateIdentityCallback != null) + { + var identity = await regenerateIdentityCallback.Invoke(signInManager, manager, user); + if (identity != null) + { + // Fix for regression where this value is not updated + // Setting it to null so that it is refreshed by the cookie middleware + context.Properties.IssuedUtc = null; + context.Properties.ExpiresUtc = null; + context.OwinContext.Authentication.SignIn(context.Properties, identity); + } + } + } + } + if (reject) + { + context.RejectIdentity(); + context.OwinContext.Authentication.SignOut(context.Options.AuthenticationType); + } + } + } + }; + } + } +} diff --git a/src/Umbraco.Web/Security/UserAwarePasswordHasher2.cs b/src/Umbraco.Web/Security/UserAwarePasswordHasher.cs similarity index 91% rename from src/Umbraco.Web/Security/UserAwarePasswordHasher2.cs rename to src/Umbraco.Web/Security/UserAwarePasswordHasher.cs index 9a44533427..d804ef0ae4 100644 --- a/src/Umbraco.Web/Security/UserAwarePasswordHasher2.cs +++ b/src/Umbraco.Web/Security/UserAwarePasswordHasher.cs @@ -4,12 +4,12 @@ using Umbraco.Web.Models.Identity; namespace Umbraco.Web.Security { - public class UserAwarePasswordHasher2 : IPasswordHasher + public class UserAwarePasswordHasher : IPasswordHasher where T : BackOfficeIdentityUser { private readonly PasswordSecurity _passwordSecurity; - public UserAwarePasswordHasher2(PasswordSecurity passwordSecurity) + public UserAwarePasswordHasher(PasswordSecurity passwordSecurity) { _passwordSecurity = passwordSecurity; } diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 703a52ccdc..9a2896a53a 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -1,4 +1,4 @@ - + @@ -205,7 +205,6 @@ - @@ -213,9 +212,9 @@ - - + + From 989767b0533a930a308ac0295cfeffc735198254 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Thu, 16 Apr 2020 11:16:35 +0100 Subject: [PATCH 13/34] Merge fixes --- src/Umbraco.Web/Security/BackOfficeUserManager.cs | 1 + src/Umbraco.Web/Umbraco.Web.csproj | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager.cs b/src/Umbraco.Web/Security/BackOfficeUserManager.cs index ee4c36e268..ab69349afc 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager.cs @@ -12,6 +12,7 @@ using Umbraco.Core.Configuration; using Umbraco.Core.Mapping; using Umbraco.Core.Security; using Umbraco.Core.Services; +using Umbraco.Net; using Umbraco.Web.Models.Identity; namespace Umbraco.Web.Security diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index a9d0b93ab6..11923b7b55 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -1,4 +1,4 @@ - + @@ -139,7 +139,6 @@ - @@ -196,8 +195,6 @@ - - From 11f5c98206311c13368d4b95e845fc76bff827bf Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Mon, 20 Apr 2020 19:45:08 +0100 Subject: [PATCH 14/34] Finished security stamp validator --- .../UmbracoSecurityStampValidatorTests.cs | 277 ++++++++++++++++++ src/Umbraco.Tests/Umbraco.Tests.csproj | 1 + .../Security/AppBuilderExtensions.cs | 2 +- .../Security/UmbracoSecurityStampValidator.cs | 14 +- 4 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 src/Umbraco.Tests/Security/UmbracoSecurityStampValidatorTests.cs diff --git a/src/Umbraco.Tests/Security/UmbracoSecurityStampValidatorTests.cs b/src/Umbraco.Tests/Security/UmbracoSecurityStampValidatorTests.cs new file mode 100644 index 0000000000..92fa032271 --- /dev/null +++ b/src/Umbraco.Tests/Security/UmbracoSecurityStampValidatorTests.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Owin; +using Microsoft.Owin.Logging; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Cookies; +using Moq; +using NUnit.Framework; +using Umbraco.Core; +using Umbraco.Core.Configuration; +using Umbraco.Core.Models.Membership; +using Umbraco.Net; +using Umbraco.Web.Models.Identity; +using Umbraco.Web.Security; + +namespace Umbraco.Tests.Security +{ + public class UmbracoSecurityStampValidatorTests + { + private Mock _mockOwinContext; + private Mock> _mockUserManager; + private Mock _mockSignInManager; + + private AuthenticationTicket _testAuthTicket; + private CookieAuthenticationOptions _testOptions; + private BackOfficeIdentityUser _testUser; + private const string _testAuthType = "cookie"; + + [Test] + public void OnValidateIdentity_When_GetUserIdCallback_Is_Null_Expect_ArgumentNullException() + { + Assert.Throws(() => UmbracoSecurityStampValidator + .OnValidateIdentity, BackOfficeIdentityUser>( + TimeSpan.MaxValue, null, null)); + } + + [Test] + public async Task OnValidateIdentity_When_Validation_Interval_Not_Met_Expect_No_Op() + { + var func = UmbracoSecurityStampValidator + .OnValidateIdentity, BackOfficeIdentityUser>( + TimeSpan.MaxValue, null, identity => throw new Exception()); + + _testAuthTicket.Properties.IssuedUtc = DateTimeOffset.UtcNow; + + var context = new CookieValidateIdentityContext( + _mockOwinContext.Object, + _testAuthTicket, + _testOptions); + + await func(context); + + Assert.AreEqual(_testAuthTicket.Identity, context.Identity); + } + + [Test] + public void OnValidateIdentity_When_Time_To_Validate_But_No_UserManager_Expect_InvalidOperationException() + { + var func = UmbracoSecurityStampValidator + .OnValidateIdentity, BackOfficeIdentityUser>( + TimeSpan.MinValue, null, identity => throw new Exception()); + + _mockOwinContext.Setup(x => x.Get>(It.IsAny())) + .Returns((BackOfficeUserManager) null); + + var context = new CookieValidateIdentityContext( + _mockOwinContext.Object, + _testAuthTicket, + _testOptions); + + Assert.ThrowsAsync(async () => await func(context)); + } + + [Test] + public void OnValidateIdentity_When_Time_To_Validate_But_No_SignInManager_Expect_InvalidOperationException() + { + var func = UmbracoSecurityStampValidator + .OnValidateIdentity, BackOfficeIdentityUser>( + TimeSpan.MinValue, null, identity => throw new Exception()); + + _mockOwinContext.Setup(x => x.Get(It.IsAny())) + .Returns((BackOfficeSignInManager) null); + + var context = new CookieValidateIdentityContext( + _mockOwinContext.Object, + _testAuthTicket, + _testOptions); + + Assert.ThrowsAsync(async () => await func(context)); + } + + [Test] + public async Task OnValidateIdentity_When_Time_To_Validate_And_User_No_Longer_Found_Expect_Rejected() + { + var userId = Guid.NewGuid().ToString(); + + var func = UmbracoSecurityStampValidator + .OnValidateIdentity, BackOfficeIdentityUser>( + TimeSpan.MinValue, null, identity => userId); + + _mockUserManager.Setup(x => x.FindByIdAsync(userId)) + .ReturnsAsync((BackOfficeIdentityUser) null); + + var context = new CookieValidateIdentityContext( + _mockOwinContext.Object, + _testAuthTicket, + _testOptions); + + await func(context); + + Assert.IsNull(context.Identity); + _mockOwinContext.Verify(x => x.Authentication.SignOut(_testAuthType), Times.Once); + } + + [Test] + public async Task OnValidateIdentity_When_Time_To_Validate_And_User_Exists_And_Does_Not_Support_SecurityStamps_Expect_Rejected() + { + var userId = Guid.NewGuid().ToString(); + + var func = UmbracoSecurityStampValidator + .OnValidateIdentity, BackOfficeIdentityUser>( + TimeSpan.MinValue, null, identity => userId); + + _mockUserManager.Setup(x => x.FindByIdAsync(userId)).ReturnsAsync(_testUser); + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(false); + + var context = new CookieValidateIdentityContext( + _mockOwinContext.Object, + _testAuthTicket, + _testOptions); + + await func(context); + + Assert.IsNull(context.Identity); + _mockOwinContext.Verify(x => x.Authentication.SignOut(_testAuthType), Times.Once); + } + + [Test] + public async Task OnValidateIdentity_When_Time_To_Validate_And_SecurityStamp_Has_Changed_Expect_Rejected() + { + var userId = Guid.NewGuid().ToString(); + + var func = UmbracoSecurityStampValidator + .OnValidateIdentity, BackOfficeIdentityUser>( + TimeSpan.MinValue, null, identity => userId); + + _mockUserManager.Setup(x => x.FindByIdAsync(userId)).ReturnsAsync(_testUser); + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(true); + _mockUserManager.Setup(x => x.GetSecurityStampAsync(_testUser)).ReturnsAsync(Guid.NewGuid().ToString); + + var context = new CookieValidateIdentityContext( + _mockOwinContext.Object, + _testAuthTicket, + _testOptions); + + await func(context); + + Assert.IsNull(context.Identity); + _mockOwinContext.Verify(x => x.Authentication.SignOut(_testAuthType), Times.Once); + } + + [Test] + public async Task OnValidateIdentity_When_Time_To_Validate_And_SecurityStamp_Has_Not_Changed_Expect_No_Change() + { + var userId = Guid.NewGuid().ToString(); + + var func = UmbracoSecurityStampValidator + .OnValidateIdentity, BackOfficeIdentityUser>( + TimeSpan.MinValue, null, identity => userId); + + _mockUserManager.Setup(x => x.FindByIdAsync(userId)).ReturnsAsync(_testUser); + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(true); + _mockUserManager.Setup(x => x.GetSecurityStampAsync(_testUser)).ReturnsAsync(_testUser.SecurityStamp); + + var context = new CookieValidateIdentityContext( + _mockOwinContext.Object, + _testAuthTicket, + _testOptions); + + await func(context); + + Assert.AreEqual(_testAuthTicket.Identity, context.Identity); + } + + [Test] + public async Task OnValidateIdentity_When_User_Validated_And_RegenerateIdentityCallback_Present_Expect_User_Refreshed() + { + var userId = Guid.NewGuid().ToString(); + var expectedIdentity = new ClaimsIdentity(new List {new Claim("sub", "bob")}); + + var regenFuncCalled = false; + Func, BackOfficeIdentityUser, Task> regenFunc = + (signInManager, userManager, user) => + { + regenFuncCalled = true; + return Task.FromResult(expectedIdentity); + }; + + var func = UmbracoSecurityStampValidator + .OnValidateIdentity, BackOfficeIdentityUser>( + TimeSpan.MinValue, regenFunc, identity => userId); + + _mockUserManager.Setup(x => x.FindByIdAsync(userId)).ReturnsAsync(_testUser); + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(true); + _mockUserManager.Setup(x => x.GetSecurityStampAsync(_testUser)).ReturnsAsync(_testUser.SecurityStamp); + + var context = new CookieValidateIdentityContext( + _mockOwinContext.Object, + _testAuthTicket, + _testOptions); + + ClaimsIdentity callbackIdentity = null; + _mockOwinContext.Setup(x => x.Authentication.SignIn(context.Properties, It.IsAny())) + .Callback((AuthenticationProperties props, ClaimsIdentity[] identities) => callbackIdentity = identities.FirstOrDefault()) + .Verifiable(); + + await func(context); + + Assert.True(regenFuncCalled); + Assert.AreEqual(expectedIdentity, callbackIdentity); + Assert.IsNull(context.Properties.IssuedUtc); + Assert.IsNull(context.Properties.ExpiresUtc); + + _mockOwinContext.Verify(); + } + + [SetUp] + public void Setup() + { + var mockGlobalSettings = new Mock(); + mockGlobalSettings.Setup(x => x.DefaultUILanguage).Returns("test"); + + _testUser = new BackOfficeIdentityUser(mockGlobalSettings.Object, 2, new List()) + { + UserName = "alice", + Name = "Alice", + Email = "alice@umbraco.test", + SecurityStamp = Guid.NewGuid().ToString() + }; + + _testAuthTicket = new AuthenticationTicket( + new ClaimsIdentity( + new List {new Claim("sub", "alice"), new Claim(Constants.Web.SecurityStampClaimType, _testUser.SecurityStamp)}, + _testAuthType), + new AuthenticationProperties()); + _testOptions = new CookieAuthenticationOptions { AuthenticationType = _testAuthType }; + + _mockUserManager = new Mock>( + new Mock().Object, + new Mock().Object, + new Mock>().Object, + null, null, null, null, null, null, null); + _mockUserManager.Setup(x => x.FindByIdAsync(It.IsAny())).ReturnsAsync((BackOfficeIdentityUser) null); + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(false); + + _mockSignInManager = new Mock( + _mockUserManager.Object, + new Mock>().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); + + _mockOwinContext = new Mock(); + _mockOwinContext.Setup(x => x.Get>(It.IsAny())) + .Returns(_mockUserManager.Object); + _mockOwinContext.Setup(x => x.Get(It.IsAny())) + .Returns(_mockSignInManager.Object); + + _mockOwinContext.Setup(x => x.Authentication.SignOut(It.IsAny())); + } + } +} diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index 3a7b33e0b5..1f54e1e629 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -148,6 +148,7 @@ + diff --git a/src/Umbraco.Web/Security/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/AppBuilderExtensions.cs index fa39bcc42b..b7218a3723 100644 --- a/src/Umbraco.Web/Security/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/AppBuilderExtensions.cs @@ -155,7 +155,7 @@ namespace Umbraco.Web.Security // logs in. This is a security feature which is used when you // change a password or add an external login to your account. OnValidateIdentity = UmbracoSecurityStampValidator - .OnValidateIdentity( + .OnValidateIdentity( TimeSpan.FromMinutes(3), (signInManager, manager, user) => signInManager.CreateUserIdentityAsync(user), identity => identity.GetUserId()), diff --git a/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs b/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs index f506a64fba..a5f9c17765 100644 --- a/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs +++ b/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs @@ -12,10 +12,11 @@ namespace Umbraco.Web.Security /// public class UmbracoSecurityStampValidator { - public static Func OnValidateIdentity( + public static Func OnValidateIdentity( TimeSpan validateInterval, Func> regenerateIdentityCallback, Func getUserIdCallback) + where TSignInManager : BackOfficeSignInManager where TManager : BackOfficeUserManager where TUser : BackOfficeIdentityUser { @@ -42,11 +43,14 @@ namespace Umbraco.Web.Security if (validate) { var manager = context.OwinContext.Get(); - var signInManager = context.OwinContext.GetBackOfficeSignInManager(); + if (manager == null) throw new InvalidOperationException("Unable to load BackOfficeUserManager"); + + var signInManager = context.OwinContext.Get(); + if (signInManager == null) throw new InvalidOperationException("Unable to load BackOfficeSignInManager"); var userId = getUserIdCallback(context.Identity); - if (manager != null && userId != null) + if (userId != null) { var user = await manager.FindByIdAsync(userId); var reject = true; @@ -55,7 +59,9 @@ namespace Umbraco.Web.Security if (user != null && manager.SupportsUserSecurityStamp) { var securityStamp = context.Identity.FindFirst(Constants.Web.SecurityStampClaimType)?.Value; - if (securityStamp == await manager.GetSecurityStampAsync(user)) + var newSecurityStamp = await manager.GetSecurityStampAsync(user); + + if (securityStamp == newSecurityStamp) { reject = false; // Regenerate fresh claims if possible and resign in From 96d9d3bd21608f3076511a8a3828c357f49cd171 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Mon, 20 Apr 2020 20:14:36 +0100 Subject: [PATCH 15/34] Removed use of Microsoft.AspNet.Identity.Owin, but had to port across some OWIN extensions --- src/Umbraco.Web.UI/Umbraco.Web.UI.csproj | 1 - .../Editors/BackOfficeController.cs | 4 +- .../Editors/BackOfficeServerVariables.cs | 1 + .../HtmlHelperBackOfficeExtensions.cs | 1 + src/Umbraco.Web/OwinExtensions.cs | 6 + .../Security/AppBuilderExtensions.cs | 40 +++++- .../AuthenticationManagerExtensions.cs | 30 ++++- .../Security/ExternalSignInAutoLinkOptions.cs | 10 +- .../Security/IdentityFactoryMiddleware.cs | 117 ++++++++++++++++++ src/Umbraco.Web/Umbraco.Web.csproj | 3 +- 10 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index f450c2300c..76af3252b4 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -88,7 +88,6 @@ - diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index d5c11ca871..684d19be81 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -374,7 +374,7 @@ namespace Umbraco.Web.Editors return await ExternalSignInAsync(loginInfo, externalSignInResponse); } - private async Task ExternalSignInAsync(ExternalLoginInfo2 loginInfo, Func response) + private async Task ExternalSignInAsync(ExternalLoginInfo loginInfo, Func response) { if (loginInfo == null) throw new ArgumentNullException("loginInfo"); if (response == null) throw new ArgumentNullException("response"); @@ -437,7 +437,7 @@ namespace Umbraco.Web.Editors return response(); } - private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo2 loginInfo, ExternalSignInAutoLinkOptions autoLinkOptions) + private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo, ExternalSignInAutoLinkOptions autoLinkOptions) { if (autoLinkOptions == null) return false; diff --git a/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs b/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs index 058f787324..4ede63b294 100644 --- a/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs +++ b/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs @@ -21,6 +21,7 @@ using Umbraco.Core.Hosting; using Umbraco.Core.IO; using Umbraco.Core.Runtime; using Umbraco.Core.WebAssets; +using Umbraco.Web.Security; namespace Umbraco.Web.Editors { diff --git a/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs b/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs index eefb02ef6d..e680f155ca 100644 --- a/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs +++ b/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs @@ -15,6 +15,7 @@ using Umbraco.Web.Composing; using Umbraco.Web.Editors; using Umbraco.Web.Features; using Umbraco.Web.Models; +using Umbraco.Web.Security; using Umbraco.Web.Trees; using Umbraco.Web.WebAssets; diff --git a/src/Umbraco.Web/OwinExtensions.cs b/src/Umbraco.Web/OwinExtensions.cs index 6905a5373a..52c1187707 100644 --- a/src/Umbraco.Web/OwinExtensions.cs +++ b/src/Umbraco.Web/OwinExtensions.cs @@ -90,6 +90,12 @@ namespace Umbraco.Web return context.Get(GetKey(typeof(T))); } + public static IOwinContext Set(this IOwinContext context, T value) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + return context.Set(GetKey(typeof(T)), value); + } + private static string GetKey(Type t) { return "AspNet.Identity.Owin:" + t.AssemblyQualifiedName; diff --git a/src/Umbraco.Web/Security/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/AppBuilderExtensions.cs index b7218a3723..9c929704e5 100644 --- a/src/Umbraco.Web/Security/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/AppBuilderExtensions.cs @@ -2,6 +2,7 @@ using System.Threading; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Owin; using Microsoft.Owin.Extensions; using Microsoft.Owin.Logging; using Microsoft.Owin.Security; @@ -14,11 +15,8 @@ using Umbraco.Core.Cache; using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Hosting; -using Umbraco.Core.IO; using Umbraco.Core.Mapping; -using Umbraco.Core.Models.Identity; using Umbraco.Net; -using Umbraco.Core.Security; using Umbraco.Core.Services; using Umbraco.Web.Composing; using Umbraco.Web.Models.Identity; @@ -421,5 +419,41 @@ namespace Umbraco.Web.Security return authOptions; } + public static IAppBuilder CreatePerOwinContext(this IAppBuilder app, Func createCallback) + where T : class, IDisposable + { + return CreatePerOwinContext(app, (options, context) => createCallback()); + } + + public static IAppBuilder CreatePerOwinContext(this IAppBuilder app, + Func, IOwinContext, T> createCallback) where T : class, IDisposable + { + if (app == null) + { + throw new ArgumentNullException("app"); + } + return app.CreatePerOwinContext(createCallback, (options, instance) => instance.Dispose()); + } + + public static IAppBuilder CreatePerOwinContext(this IAppBuilder app, + Func, IOwinContext, T> createCallback, + Action, T> disposeCallback) where T : class, IDisposable + { + if (app == null) throw new ArgumentNullException(nameof(app)); + if (createCallback == null) throw new ArgumentNullException(nameof(createCallback)); + if (disposeCallback == null) throw new ArgumentNullException(nameof(disposeCallback)); + + app.Use(typeof(IdentityFactoryMiddleware>), + new IdentityFactoryOptions + { + DataProtectionProvider = app.GetDataProtectionProvider(), + Provider = new IdentityFactoryProvider + { + OnCreate = createCallback, + OnDispose = disposeCallback + } + }); + return app; + } } } diff --git a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs index d648d9ce78..84b9fcbe0b 100644 --- a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs @@ -1,14 +1,16 @@ using System; +using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNet.Identity; using Microsoft.Owin.Security; +using UserLoginInfo = Microsoft.AspNetCore.Identity.UserLoginInfo; namespace Umbraco.Web.Security { public static class AuthenticationManagerExtensions { - private static ExternalLoginInfo2 GetExternalLoginInfo(AuthenticateResult result) + private static ExternalLoginInfo GetExternalLoginInfo(AuthenticateResult result) { if (result == null || result.Identity == null) { @@ -27,7 +29,7 @@ namespace Umbraco.Web.Security } var email = result.Identity.FindFirst(ClaimTypes.Email)?.Value; - return new ExternalLoginInfo2 + return new ExternalLoginInfo { ExternalIdentity = result.Identity, Login = new UserLoginInfo(idClaim.Issuer, idClaim.Value, idClaim.Issuer), @@ -47,7 +49,7 @@ namespace Umbraco.Web.Security /// dictionary /// /// - public static async Task GetExternalLoginInfoAsync(this IAuthenticationManager manager, + public static async Task GetExternalLoginInfoAsync(this IAuthenticationManager manager, string authenticationType, string xsrfKey, string expectedValue) { @@ -74,7 +76,7 @@ namespace Umbraco.Web.Security /// /// /// - public static async Task GetExternalLoginInfoAsync(this IAuthenticationManager manager, string authenticationType) + public static async Task GetExternalLoginInfoAsync(this IAuthenticationManager manager, string authenticationType) { if (manager == null) { @@ -82,9 +84,25 @@ namespace Umbraco.Web.Security } return GetExternalLoginInfo(await manager.AuthenticateAsync(authenticationType)); } + + public static IEnumerable GetExternalAuthenticationTypes(this IAuthenticationManager manager) + { + if (manager == null) throw new ArgumentNullException(nameof(manager)); + return manager.GetAuthenticationTypes(d => d.Properties != null && d.Properties.ContainsKey("Caption")); + } + + public static ClaimsIdentity CreateTwoFactorRememberBrowserIdentity(this IAuthenticationManager manager, string userId) + { + if (manager == null) throw new ArgumentNullException(nameof(manager)); + + var rememberBrowserIdentity = new ClaimsIdentity(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie); + rememberBrowserIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId)); + + return rememberBrowserIdentity; + } } - public class ExternalLoginInfo2 + public class ExternalLoginInfo { /// Associated login data public UserLoginInfo Login { get; set; } diff --git a/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs index fd6446f5ab..bd0d3b98a4 100644 --- a/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs +++ b/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs @@ -31,13 +31,13 @@ namespace Umbraco.Web.Security /// /// A callback executed during account auto-linking and before the user is persisted /// - public Action OnAutoLinking { get; set; } + public Action OnAutoLinking { get; set; } /// /// A callback executed during every time a user authenticates using an external login. /// returns a boolean indicating if sign in should continue or not. /// - public Func OnExternalLogin { get; set; } + public Func OnExternalLogin { get; set; } /// B @@ -46,7 +46,7 @@ namespace Umbraco.Web.Security /// /// /// - public string[] GetDefaultUserGroups(IUmbracoContext umbracoContext, ExternalLoginInfo2 loginInfo) + public string[] GetDefaultUserGroups(IUmbracoContext umbracoContext, ExternalLoginInfo loginInfo) { return _defaultUserGroups; } @@ -59,7 +59,7 @@ namespace Umbraco.Web.Security /// /// For public auth providers this should always be false!!! /// - public bool ShouldAutoLinkExternalAccount(IUmbracoContext umbracoContext, ExternalLoginInfo2 loginInfo) + public bool ShouldAutoLinkExternalAccount(IUmbracoContext umbracoContext, ExternalLoginInfo loginInfo) { return _autoLinkExternalAccount; } @@ -69,7 +69,7 @@ namespace Umbraco.Web.Security /// /// The default Culture to use for auto-linking users /// - public string GetDefaultCulture(IUmbracoContext umbracoContext, ExternalLoginInfo2 loginInfo) + public string GetDefaultCulture(IUmbracoContext umbracoContext, ExternalLoginInfo loginInfo) { return _defaultCulture; } diff --git a/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs b/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs new file mode 100644 index 0000000000..ddd0703878 --- /dev/null +++ b/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Owin; +using Microsoft.Owin.Security.DataProtection; + +namespace Umbraco.Web.Security +{ + /// + /// Adapted from Microsoft.AspNet.Identity.Owin.IdentityFactoryMiddleware + /// + public class IdentityFactoryMiddleware : OwinMiddleware + where TResult : class, IDisposable + where TOptions : IdentityFactoryOptions + { + /// + /// Constructor + /// + /// The next middleware in the OWIN pipeline to invoke + /// Configuration options for the middleware + public IdentityFactoryMiddleware(OwinMiddleware next, TOptions options) + : base(next) + { + if (options == null) + { + throw new ArgumentNullException("options"); + } + if (options.Provider == null) + { + throw new ArgumentNullException("options.Provider"); + } + Options = options; + } + + /// + /// Configuration options + /// + public TOptions Options { get; private set; } + + /// + /// Create an object using the Options.Provider, storing it in the OwinContext and then disposes the object when finished + /// + /// + /// + public override async Task Invoke(IOwinContext context) + { + var instance = Options.Provider.Create(Options, context); + try + { + context.Set(instance); + if (Next != null) + { + await Next.Invoke(context); + } + } + finally + { + Options.Provider.Dispose(Options, instance); + } + } + } + + public class IdentityFactoryOptions where T : class, IDisposable + { + /// + /// Used to configure the data protection provider + /// + public IDataProtectionProvider DataProtectionProvider { get; set; } + + /// + /// Provider used to Create and Dispose objects + /// + public IdentityFactoryProvider Provider { get; set; } + } + + public class IdentityFactoryProvider where T : class, IDisposable + { + /// + /// Constructor + /// + public IdentityFactoryProvider() + { + OnDispose = (options, instance) => { }; + OnCreate = (options, context) => null; + } + + /// + /// A delegate assigned to this property will be invoked when the related method is called + /// + public Func, IOwinContext, T> OnCreate { get; set; } + + /// + /// A delegate assigned to this property will be invoked when the related method is called + /// + public Action, T> OnDispose { get; set; } + + /// + /// Calls the OnCreate Delegate + /// + /// + /// + /// + public virtual T Create(IdentityFactoryOptions options, IOwinContext context) + { + return OnCreate(options, context); + } + + /// + /// Calls the OnDispose delegate + /// + /// + /// + public virtual void Dispose(IdentityFactoryOptions options, T instance) + { + OnDispose(options, instance); + } + } +} diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 11923b7b55..81e011fe32 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -77,7 +77,7 @@ - + @@ -147,6 +147,7 @@ + From e90778bd3d1ed95d6bce4e73bcab805cb1b22c91 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Mon, 20 Apr 2020 20:20:04 +0100 Subject: [PATCH 16/34] Extracted constant for 2FA remember me cookie --- src/Umbraco.Core/Constants-Web.cs | 5 +++++ src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Core/Constants-Web.cs b/src/Umbraco.Core/Constants-Web.cs index 26e66c8cfc..5602b99e2d 100644 --- a/src/Umbraco.Core/Constants-Web.cs +++ b/src/Umbraco.Core/Constants-Web.cs @@ -44,6 +44,11 @@ /// The claim type for the ASP.NET Identity security stamp /// public const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp"; + + /// + /// The default authentication type used for remembering that 2FA is not needed on next login + /// + public const string TwoFactorRememberBrowserCookie = "TwoFactorRememberBrowser"; } } } diff --git a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs index 84b9fcbe0b..f809c402ae 100644 --- a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.AspNet.Identity; +using Microsoft.AspNetCore.Identity; using Microsoft.Owin.Security; -using UserLoginInfo = Microsoft.AspNetCore.Identity.UserLoginInfo; +using Umbraco.Core; namespace Umbraco.Web.Security { @@ -95,7 +95,7 @@ namespace Umbraco.Web.Security { if (manager == null) throw new ArgumentNullException(nameof(manager)); - var rememberBrowserIdentity = new ClaimsIdentity(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie); + var rememberBrowserIdentity = new ClaimsIdentity(Constants.Web.TwoFactorRememberBrowserCookie); rememberBrowserIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId)); return rememberBrowserIdentity; From 8fc2674426ef2f87ee340ddfda49391e12d39dd3 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Wed, 22 Apr 2020 15:58:28 +0100 Subject: [PATCH 17/34] Tests for v7 password hash --- .../Security/BackOfficeUserManagerTests.cs | 62 +++++++++++++++++++ src/Umbraco.Tests/Umbraco.Tests.csproj | 1 + .../Editors/BackOfficeController.cs | 2 +- .../Security/ExternalSignInAutoLinkOptions.cs | 2 +- 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/Umbraco.Tests/Security/BackOfficeUserManagerTests.cs diff --git a/src/Umbraco.Tests/Security/BackOfficeUserManagerTests.cs b/src/Umbraco.Tests/Security/BackOfficeUserManagerTests.cs new file mode 100644 index 0000000000..30ed101297 --- /dev/null +++ b/src/Umbraco.Tests/Security/BackOfficeUserManagerTests.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Owin.Security.DataProtection; +using Moq; +using NUnit.Framework; +using Umbraco.Core.Configuration; +using Umbraco.Core.Models.Membership; +using Umbraco.Net; +using Umbraco.Web.Models.Identity; +using Umbraco.Web.Security; + +namespace Umbraco.Tests.Security +{ + public class BackOfficeUserManagerTests + { + [Test] + public async Task CheckPasswordAsync_When_Default_Password_Hasher_Validates_Umbraco7_Hash_Expect_Valid_Password() + { + const string v7Hash = "7Uob6fMTTxDIhWGebYiSxg==P+hgvWlXLbDd4cFLADn811KOaVI/9pg1PNvTuG5NklY="; + const string plaintext = "4XxzH3s3&J"; + + var mockPasswordConfiguration = new Mock(); + var mockIpResolver = new Mock(); + var mockUserStore = new Mock>(); + var mockDataProtectionProvider = new Mock(); + + mockDataProtectionProvider.Setup(x => x.Create(It.IsAny())) + .Returns(new Mock().Object); + mockPasswordConfiguration.Setup(x => x.HashAlgorithmType) + .Returns("HMACSHA256"); + + var userManager = BackOfficeUserManager.Create( + mockPasswordConfiguration.Object, + mockIpResolver.Object, + mockUserStore.Object, + null, + mockDataProtectionProvider.Object, + new NullLogger>()); + + var mockGlobalSettings = new Mock(); + mockGlobalSettings.Setup(x => x.DefaultUILanguage).Returns("test"); + + var user = new BackOfficeIdentityUser(mockGlobalSettings.Object, 2, new List()) + { + UserName = "alice", + Name = "Alice", + Email = "alice@umbraco.test", + PasswordHash = v7Hash + }; + + mockUserStore.Setup(x => x.GetPasswordHashAsync(user, It.IsAny())) + .ReturnsAsync(v7Hash); + + var isValidPassword = await userManager.CheckPasswordAsync(user, plaintext); + + Assert.True(isValidPassword); + } + } +} diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index 1f54e1e629..c36d35a56d 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -148,6 +148,7 @@ + diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index 684d19be81..78a5eaaa38 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using System.Web; using System.Web.Mvc; using System.Web.UI; +using Microsoft.AspNetCore.Identity; using Microsoft.Owin.Security; using Newtonsoft.Json; using Umbraco.Core; @@ -31,7 +32,6 @@ using Umbraco.Core.Runtime; using Umbraco.Core.WebAssets; using Umbraco.Web.Trees; using Umbraco.Web.WebAssets; -using UserLoginInfo = Microsoft.AspNetCore.Identity.UserLoginInfo; namespace Umbraco.Web.Editors { diff --git a/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs index bd0d3b98a4..abe5aeb196 100644 --- a/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs +++ b/src/Umbraco.Web/Security/ExternalSignInAutoLinkOptions.cs @@ -40,7 +40,7 @@ namespace Umbraco.Web.Security public Func OnExternalLogin { get; set; } - /// B + /// /// The default User group aliases to use for auto-linking users /// /// From 4763c28c4aa4c6e53b194a4061abc0bff69567f2 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Wed, 22 Apr 2020 20:05:06 +0100 Subject: [PATCH 18/34] Completed No-op lookup normalizer and OWIN daa protection token provider --- .../Security/NopLookupNormalizerTests.cs | 52 ++++ .../OwinDataProtectorTokenProviderTests.cs | 246 ++++++++++++++++++ src/Umbraco.Tests/Umbraco.Tests.csproj | 2 + .../Security/NopLookupNormalizer.cs | 16 +- .../OwinDataProtectorTokenProvider.cs | 39 +-- 5 files changed, 323 insertions(+), 32 deletions(-) create mode 100644 src/Umbraco.Tests/Security/NopLookupNormalizerTests.cs create mode 100644 src/Umbraco.Tests/Security/OwinDataProtectorTokenProviderTests.cs diff --git a/src/Umbraco.Tests/Security/NopLookupNormalizerTests.cs b/src/Umbraco.Tests/Security/NopLookupNormalizerTests.cs new file mode 100644 index 0000000000..1e035035dc --- /dev/null +++ b/src/Umbraco.Tests/Security/NopLookupNormalizerTests.cs @@ -0,0 +1,52 @@ +using System; +using NUnit.Framework; +using Umbraco.Web.Security; + +namespace Umbraco.Tests.Security +{ + public class NopLookupNormalizerTests + { + [Test] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void NormalizeName_When_Name_Null_Or_Whitespace_Expect_ArgumentNullException(string name) + { + var sut = new NopLookupNormalizer(); + + Assert.Throws(() => sut.NormalizeName(name)); + } + + [Test] + public void NormalizeName_Expect_Input_Returned() + { + var name = Guid.NewGuid().ToString(); + var sut = new NopLookupNormalizer(); + + var normalizedName = sut.NormalizeName(name); + + Assert.AreEqual(name, normalizedName); + } + [Test] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void NormalizeEmail_When_Name_Null_Or_Whitespace_Expect_ArgumentNullException(string email) + { + var sut = new NopLookupNormalizer(); + + Assert.Throws(() => sut.NormalizeEmail(email)); + } + + [Test] + public void NormalizeEmail_Expect_Input_Returned() + { + var name = $"{Guid.NewGuid()}@umbraco"; + var sut = new NopLookupNormalizer(); + + var normalizedName = sut.NormalizeEmail(name); + + Assert.AreEqual(name, normalizedName); + } + } +} diff --git a/src/Umbraco.Tests/Security/OwinDataProtectorTokenProviderTests.cs b/src/Umbraco.Tests/Security/OwinDataProtectorTokenProviderTests.cs new file mode 100644 index 0000000000..5e9b731aa9 --- /dev/null +++ b/src/Umbraco.Tests/Security/OwinDataProtectorTokenProviderTests.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Owin.Security.DataProtection; +using Moq; +using NUnit.Framework; +using Umbraco.Core.Configuration; +using Umbraco.Core.Models.Membership; +using Umbraco.Web.Models.Identity; +using Umbraco.Web.Security; + +namespace Umbraco.Tests.Security +{ + public class OwinDataProtectorTokenProviderTests + { + private Mock _mockDataProtector; + private Mock> _mockUserManager; + private BackOfficeIdentityUser _testUser; + private const string _testPurpose = "test"; + + [Test] + public void Ctor_When_Protector_Is_Null_Expect_ArgumentNullException() + { + Assert.Throws(() => new OwinDataProtectorTokenProvider(null)); + } + + [Test] + public async Task CanGenerateTwoFactorTokenAsync_Expect_False() + { + var sut = CreateSut(); + + var canGenerate = await sut.CanGenerateTwoFactorTokenAsync(_mockUserManager.Object, _testUser); + + Assert.False(canGenerate); + } + + [Test] + public void GenerateAsync_When_UserManager_Is_Null_Expect_ArgumentNullException() + { + var sut = CreateSut(); + Assert.ThrowsAsync(async () => await sut.GenerateAsync(null, null, _testUser)); + } + + [Test] + public void GenerateAsync_When_User_Is_Null_Expect_ArgumentNullException() + { + var sut = CreateSut(); + Assert.ThrowsAsync(async () => await sut.GenerateAsync(null, _mockUserManager.Object, null)); + } + + [Test] + public async Task GenerateAsync_When_Token_Generated_Expect_Ticks_And_User_ID() + { + var sut = CreateSut(); + + var token = await sut.GenerateAsync(null, _mockUserManager.Object, _testUser); + + using (var reader = new BinaryReader(new MemoryStream(Convert.FromBase64String(token)))) + { + var creationTime = new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero); + var foundUserId = reader.ReadString(); + + Assert.That(creationTime.DateTime, Is.EqualTo(DateTime.UtcNow).Within(1).Minutes); + Assert.AreEqual(_testUser.Id.ToString(), foundUserId); + } + } + + [Test] + public async Task GenerateAsync_When_Token_Generated_With_Purpose_Expect_Purpose_In_Token() + { + var expectedPurpose = Guid.NewGuid().ToString(); + + var sut = CreateSut(); + + var token = await sut.GenerateAsync(expectedPurpose, _mockUserManager.Object, _testUser); + + using (var reader = new BinaryReader(new MemoryStream(Convert.FromBase64String(token)))) + { + reader.ReadInt64(); // creation time + reader.ReadString(); // user ID + var purpose = reader.ReadString(); + + Assert.AreEqual(expectedPurpose, purpose); + } + } + + [Test] + public async Task GenerateAsync_When_Token_Generated_And_SecurityStamp_Supported_Expect_SecurityStamp_In_Token() + { + var expectedSecurityStamp = Guid.NewGuid().ToString(); + + var sut = CreateSut(); + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(true); + _mockUserManager.Setup(x => x.GetSecurityStampAsync(_testUser)).ReturnsAsync(expectedSecurityStamp); + + var token = await sut.GenerateAsync(null, _mockUserManager.Object, _testUser); + + using (var reader = new BinaryReader(new MemoryStream(Convert.FromBase64String(token)))) + { + reader.ReadInt64(); // creation time + reader.ReadString(); // user ID + reader.ReadString(); // purpose + var securityStamp = reader.ReadString(); + + Assert.AreEqual(expectedSecurityStamp, securityStamp); + } + } + + [Test] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void ValidateAsync_When_Token_Is_Null_Or_Whitespace_Expect_ArgumentNullException(string token) + { + var sut = CreateSut(); + + Assert.ThrowsAsync(() => sut.ValidateAsync(null, token, _mockUserManager.Object, _testUser)); + } + + [Test] + public void ValidateAsync_When_UserManager_Is_Null_Expect_ArgumentNullException() + { + var sut = CreateSut(); + + Assert.ThrowsAsync(() => sut.ValidateAsync(null, Guid.NewGuid().ToString(), null, _testUser)); + } + + [Test] + public void ValidateAsync_When_User_Is_Null_Expect_ArgumentNullException() + { + var sut = CreateSut(); + + Assert.ThrowsAsync(() => sut.ValidateAsync(null, Guid.NewGuid().ToString(), _mockUserManager.Object, null)); + } + + [Test] + public async Task ValidateAsync_When_Token_Has_Expired_Expect_False() + { + var sut = CreateSut(); + var testToken = CreateTestToken(creationDate: DateTime.UtcNow.AddYears(-10)); + + var isValid = await sut.ValidateAsync(null, testToken, _mockUserManager.Object, _testUser); + + Assert.False(isValid); + } + + [Test] + public async Task ValidateAsync_When_Token_Was_Issued_To_Wrong_User_Expect_False() + { + var sut = CreateSut(); + var testToken = CreateTestToken(userId: Guid.NewGuid().ToString()); + + var isValid = await sut.ValidateAsync(_testPurpose, testToken, _mockUserManager.Object, _testUser); + + Assert.False(isValid); + } + + [Test] + public async Task ValidateAsync_When_Token_Was_Has_Wrong_Purpose_Expect_False() + { + var sut = CreateSut(); + var testToken = CreateTestToken(purpose: "invalid"); + + var isValid = await sut.ValidateAsync("valid", testToken, _mockUserManager.Object, _testUser); + + Assert.False(isValid); + } + + [Test] + public async Task ValidateAsync_When_Token_Was_Has_Wrong_SecurityStamp_Expect_False() + { + var sut = CreateSut(); + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(true); + _mockUserManager.Setup(x => x.GetSecurityStampAsync(_testUser)).ReturnsAsync(Guid.NewGuid().ToString); + + var testToken = CreateTestToken(securityStamp: "invalid"); + + var isValid = await sut.ValidateAsync(_testPurpose, testToken, _mockUserManager.Object, _testUser); + + Assert.False(isValid); + } + + [Test] + public async Task ValidateAsync_When_Valid_Token_Expect_True() + { + const string validPurpose = "test"; + var validSecurityStamp = Guid.NewGuid().ToString(); + + var sut = CreateSut(); + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(true); + _mockUserManager.Setup(x => x.GetSecurityStampAsync(_testUser)).ReturnsAsync(validSecurityStamp); + + var testToken = CreateTestToken( + creationDate: DateTime.UtcNow, + userId: _testUser.Id.ToString(), + purpose: validPurpose, + securityStamp: validSecurityStamp); + + var isValid = await sut.ValidateAsync(validPurpose, testToken, _mockUserManager.Object, _testUser); + + Assert.True(isValid); + } + + private OwinDataProtectorTokenProvider CreateSut() + => new OwinDataProtectorTokenProvider(_mockDataProtector.Object); + + private string CreateTestToken(DateTime? creationDate = null, string userId = null, string purpose = null, string securityStamp = null) + { + var ms = new MemoryStream(); + using (var writer = new BinaryWriter(ms)) + { + writer.Write(creationDate?.Ticks ?? DateTimeOffset.UtcNow.UtcTicks); + writer.Write(userId ?? _testUser.Id.ToString()); + writer.Write(purpose ?? _testPurpose); + writer.Write(securityStamp ?? ""); + } + + return Convert.ToBase64String(ms.ToArray()); + } + + [SetUp] + public void Setup() + { + _mockDataProtector = new Mock(); + _mockDataProtector.Setup(x => x.Protect(It.IsAny())).Returns((byte[] originalBytes) => originalBytes); + _mockDataProtector.Setup(x => x.Unprotect(It.IsAny())).Returns((byte[] originalBytes) => originalBytes); + + var mockGlobalSettings = new Mock(); + mockGlobalSettings.Setup(x => x.DefaultUILanguage).Returns("test"); + + _mockUserManager = new Mock>(new Mock>().Object, + null, null, null, null, null, null, null, null); + _mockUserManager.Setup(x => x.SupportsUserSecurityStamp).Returns(false); + + _testUser = new BackOfficeIdentityUser(mockGlobalSettings.Object, 2, new List()) + { + UserName = "alice", + Name = "Alice", + Email = "alice@umbraco.test", + SecurityStamp = Guid.NewGuid().ToString() + }; + } + } +} diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index c36d35a56d..b0c0321925 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -149,6 +149,8 @@ + + diff --git a/src/Umbraco.Web/Security/NopLookupNormalizer.cs b/src/Umbraco.Web/Security/NopLookupNormalizer.cs index 211cea076e..48e42c2d5e 100644 --- a/src/Umbraco.Web/Security/NopLookupNormalizer.cs +++ b/src/Umbraco.Web/Security/NopLookupNormalizer.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Identity; +using System; +using Microsoft.AspNetCore.Identity; namespace Umbraco.Web.Security { @@ -7,7 +8,16 @@ namespace Umbraco.Web.Security /// public class NopLookupNormalizer : ILookupNormalizer { - public string NormalizeName(string name) => name; - public string NormalizeEmail(string email) => email; + public string NormalizeName(string name) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + return name; + } + + public string NormalizeEmail(string email) + { + if (string.IsNullOrWhiteSpace(email)) throw new ArgumentNullException(nameof(email)); + return email; + } } } diff --git a/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs b/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs index 51a42aae60..15bd4dfd75 100644 --- a/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs +++ b/src/Umbraco.Web/Security/OwinDataProtectorTokenProvider.cs @@ -15,6 +15,7 @@ namespace Umbraco.Web.Security public class OwinDataProtectorTokenProvider : IUserTwoFactorTokenProvider where TUser : BackOfficeIdentityUser { public TimeSpan TokenLifespan { get; set; } + private static readonly Encoding _defaultEncoding = new UTF8Encoding(false, true); private readonly IDataProtector _protector; public OwinDataProtectorTokenProvider(IDataProtector protector) @@ -25,12 +26,13 @@ namespace Umbraco.Web.Security public async Task GenerateAsync(string purpose, UserManager manager, TUser user) { + if (manager == null) throw new ArgumentNullException(nameof(manager)); if (user == null) throw new ArgumentNullException(nameof(user)); var ms = new MemoryStream(); - using (var writer = ms.CreateWriter()) + using (var writer = new BinaryWriter(ms, _defaultEncoding, true)) { - writer.Write(DateTimeOffset.UtcNow); + writer.Write(DateTimeOffset.UtcNow.UtcTicks); writer.Write(Convert.ToString(user.Id, CultureInfo.InvariantCulture)); writer.Write(purpose ?? ""); @@ -48,13 +50,17 @@ namespace Umbraco.Web.Security public async Task ValidateAsync(string purpose, string token, UserManager manager, TUser user) { + if (string.IsNullOrWhiteSpace(token)) throw new ArgumentNullException(nameof(token)); + if (manager == null) throw new ArgumentNullException(nameof(manager)); + if (user == null) throw new ArgumentNullException(nameof(user)); + try { var unprotectedData = _protector.Unprotect(Convert.FromBase64String(token)); var ms = new MemoryStream(unprotectedData); - using (var reader = ms.CreateReader()) + using (var reader = new BinaryReader(ms, _defaultEncoding, true)) { - var creationTime = reader.ReadDateTimeOffset(); + var creationTime = new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero); var expirationTime = creationTime + TokenLifespan; if (expirationTime < DateTimeOffset.UtcNow) { @@ -103,29 +109,4 @@ namespace Umbraco.Web.Security return Task.FromResult(false); } } - - internal static class StreamExtensions - { - private static readonly Encoding DefaultEncoding = new UTF8Encoding(false, true); - - public static BinaryReader CreateReader(this Stream stream) - { - return new BinaryReader(stream, DefaultEncoding, true); - } - - public static BinaryWriter CreateWriter(this Stream stream) - { - return new BinaryWriter(stream, DefaultEncoding, true); - } - - public static DateTimeOffset ReadDateTimeOffset(this BinaryReader reader) - { - return new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero); - } - - public static void Write(this BinaryWriter writer, DateTimeOffset value) - { - writer.Write(value.UtcTicks); - } - } } From d6036eb9f48f117d203f72a62222de830e2a9c7e Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Wed, 22 Apr 2020 20:28:49 +0100 Subject: [PATCH 19/34] Added ASP.NET Core Identity token providers --- src/Umbraco.Web/Editors/BackOfficeController.cs | 2 +- src/Umbraco.Web/Security/BackOfficeUserManager.cs | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index 78a5eaaa38..34bb2e6e03 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -300,7 +300,7 @@ namespace Umbraco.Web.Editors var user = await UserManager.FindByIdAsync(userId.ToString()); if (user != null) { - var result = await UserManager.VerifyUserTokenAsync(user, "ResetPassword", "ResetPassword", resetCode); // TODO: SB: password reset token provider + var result = await UserManager.VerifyUserTokenAsync(user, "ResetPassword", "ResetPassword", resetCode); if (result) { //Add a flag and redirect for it to be displayed diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager.cs b/src/Umbraco.Web/Security/BackOfficeUserManager.cs index ab69349afc..805c6b902c 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager.cs @@ -164,20 +164,24 @@ namespace Umbraco.Web.Security BackOfficeUserManager manager, IDataProtectionProvider dataProtectionProvider) { - //use a custom hasher based on our membership provider + // use a custom hasher based on our membership provider PasswordHasher = GetDefaultPasswordHasher(PasswordConfiguration); - // TODO: SB: manager.Options.Tokens using OWIN data protector - what about the other providers??? - // https://github.com/dotnet/aspnetcore/blob/0a0e1ea0cdbe29f2fcd2291b900db98597387d77/src/Identity/Core/src/IdentityBuilderExtensions.cs#L28 + // set OWIN data protection token provider as default if (dataProtectionProvider != null) { manager.RegisterTokenProvider( - "Default", + TokenOptions.DefaultProvider, new OwinDataProtectorTokenProvider(dataProtectionProvider.Create("ASP.NET Identity")) { TokenLifespan = TimeSpan.FromDays(3) }); } + + // register ASP.NET Core Identity token providers + manager.RegisterTokenProvider(TokenOptions.DefaultEmailProvider, new EmailTokenProvider()); + manager.RegisterTokenProvider(TokenOptions.DefaultPhoneProvider, new PhoneNumberTokenProvider()); + manager.RegisterTokenProvider(TokenOptions.DefaultAuthenticatorProvider, new AuthenticatorTokenProvider()); } /// From 42efb78fddf460dc08de9f5cb34a795336291adc Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Thu, 23 Apr 2020 16:03:12 +0100 Subject: [PATCH 20/34] Addressed final TODOs --- src/Umbraco.Web/Editors/AuthenticationController.cs | 6 ++---- src/Umbraco.Web/Editors/UsersController.cs | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index b89f06897d..44f41a0114 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Net; using System.Net.Http; @@ -327,7 +327,7 @@ namespace Umbraco.Web.Editors UmbracoUserExtensions.GetUserCulture(identityUser.Culture, Services.TextService, GlobalSettings), new[] { identityUser.UserName, callbackUrl }); - // TODO: SB: SendEmailAsync + // TODO: Port email service to ASP.NET Core /*await UserManager.SendEmailAsync(identityUser.Id, Services.TextService.Localize("login/resetPasswordEmailCopySubject", // Ensure the culture of the found user is used for the email! @@ -451,8 +451,6 @@ namespace Umbraco.Web.Editors // if user was only invited, then they have not been approved // but a successful forgot password flow (e.g. if their token had expired and they did a forgot password instead of request new invite) // means we have verified their email - - // TODO: SB: ConfirmEmailAsync if (!await UserManager.IsEmailConfirmedAsync(identityUser)) { await UserManager.ConfirmEmailAsync(identityUser, model.ResetCode); diff --git a/src/Umbraco.Web/Editors/UsersController.cs b/src/Umbraco.Web/Editors/UsersController.cs index dd6a1d32a6..8e73fa1d02 100644 --- a/src/Umbraco.Web/Editors/UsersController.cs +++ b/src/Umbraco.Web/Editors/UsersController.cs @@ -499,7 +499,7 @@ namespace Umbraco.Web.Editors UmbracoUserExtensions.GetUserCulture(to.Language, Services.TextService, GlobalSettings), new[] { userDisplay.Name, from, message, inviteUri.ToString(), fromEmail }); - // TODO: SB: EmailService.SendAsync + // TODO: Port email service to ASP.NET Core /*await UserManager.EmailService.SendAsync( //send the special UmbracoEmailMessage which configures it's own sender //to allow for events to handle sending the message if no smtp is configured From 9c9bd1dd1e5d98149487fa4dbde3c9f14fe7f0a0 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Fri, 24 Apr 2020 12:48:29 +0100 Subject: [PATCH 21/34] Merge fixes --- src/Umbraco.Web/Security/BackOfficeSignInManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs index 1981f6856a..bb408479c5 100644 --- a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs @@ -139,7 +139,7 @@ namespace Umbraco.Web.Security $"Login attempt failed for username {userName} from IP address {_request.RemoteIpAddress}, no content and/or media start nodes could be found for any of the user's groups", null, null); // We will say its a sucessful login which it is, but they have no node access - return SignInStatus.Success; + return SignInResult.Success; } } From 0338c2fc4a78dfcdd53dfcb069249770406dbc78 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Fri, 24 Apr 2020 12:52:49 +0100 Subject: [PATCH 22/34] Removed null checks from normalizer (breaks Umbraco bootstrap process) --- .../Security/NopLookupNormalizerTests.cs | 18 +++++++++++------- .../Security/NopLookupNormalizer.cs | 15 +++------------ 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/Umbraco.Tests/Security/NopLookupNormalizerTests.cs b/src/Umbraco.Tests/Security/NopLookupNormalizerTests.cs index 1e035035dc..2abecbb4dd 100644 --- a/src/Umbraco.Tests/Security/NopLookupNormalizerTests.cs +++ b/src/Umbraco.Tests/Security/NopLookupNormalizerTests.cs @@ -10,11 +10,13 @@ namespace Umbraco.Tests.Security [TestCase(null)] [TestCase("")] [TestCase(" ")] - public void NormalizeName_When_Name_Null_Or_Whitespace_Expect_ArgumentNullException(string name) + public void NormalizeName_When_Name_Null_Or_Whitespace_Expect_Same_Returned(string name) { var sut = new NopLookupNormalizer(); - Assert.Throws(() => sut.NormalizeName(name)); + var normalizedName = sut.NormalizeName(name); + + Assert.AreEqual(name, normalizedName); } [Test] @@ -31,22 +33,24 @@ namespace Umbraco.Tests.Security [TestCase(null)] [TestCase("")] [TestCase(" ")] - public void NormalizeEmail_When_Name_Null_Or_Whitespace_Expect_ArgumentNullException(string email) + public void NormalizeEmail_When_Name_Null_Or_Whitespace_Expect_Same_Returned(string email) { var sut = new NopLookupNormalizer(); - Assert.Throws(() => sut.NormalizeEmail(email)); + var normalizedEmail = sut.NormalizeEmail(email); + + Assert.AreEqual(email, normalizedEmail); } [Test] public void NormalizeEmail_Expect_Input_Returned() { - var name = $"{Guid.NewGuid()}@umbraco"; + var email = $"{Guid.NewGuid()}@umbraco"; var sut = new NopLookupNormalizer(); - var normalizedName = sut.NormalizeEmail(name); + var normalizedEmail = sut.NormalizeEmail(email); - Assert.AreEqual(name, normalizedName); + Assert.AreEqual(email, normalizedEmail); } } } diff --git a/src/Umbraco.Web/Security/NopLookupNormalizer.cs b/src/Umbraco.Web/Security/NopLookupNormalizer.cs index 48e42c2d5e..08aa8d548a 100644 --- a/src/Umbraco.Web/Security/NopLookupNormalizer.cs +++ b/src/Umbraco.Web/Security/NopLookupNormalizer.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; namespace Umbraco.Web.Security { @@ -8,16 +7,8 @@ namespace Umbraco.Web.Security /// public class NopLookupNormalizer : ILookupNormalizer { - public string NormalizeName(string name) - { - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); - return name; - } + public string NormalizeName(string name) => name; - public string NormalizeEmail(string email) - { - if (string.IsNullOrWhiteSpace(email)) throw new ArgumentNullException(nameof(email)); - return email; - } + public string NormalizeEmail(string email) => email; } } From 1862bf033f3b3d9273f9600bd1a6d116a6712044 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Tue, 28 Apr 2020 12:20:04 +0100 Subject: [PATCH 23/34] Normalized int to string conversion --- .../Editors/AuthenticationController.cs | 6 ++-- .../Security/BackOfficeSignInManager.cs | 35 ++++--------------- .../Security/BackOfficeUserManager.cs | 5 +-- .../Security/BackOfficeUserStore.cs | 17 ++------- 4 files changed, 14 insertions(+), 49 deletions(-) diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index 44f41a0114..a0143548fc 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -349,13 +349,13 @@ namespace Umbraco.Web.Editors public async Task> Get2FAProviders() { var userId = await SignInManager.GetVerifiedUserIdAsync(); - if (userId == int.MinValue) + if (string.IsNullOrWhiteSpace(userId)) { Logger.Warn("Get2FAProviders :: No verified user found, returning 404"); throw new HttpResponseException(HttpStatusCode.NotFound); } - var user = await UserManager.FindByIdAsync(userId.ToString()); + var user = await UserManager.FindByIdAsync(userId); var userFactors = await UserManager.GetValidTwoFactorProvidersAsync(user); return userFactors; @@ -368,7 +368,7 @@ namespace Umbraco.Web.Editors throw new HttpResponseException(HttpStatusCode.NotFound); var userId = await SignInManager.GetVerifiedUserIdAsync(); - if (userId == int.MinValue) + if (string.IsNullOrWhiteSpace(userId)) { Logger.Warn("Get2FAProviders :: No verified user found, returning 404"); throw new HttpResponseException(HttpStatusCode.NotFound); diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs index bb408479c5..bbb4328fc3 100644 --- a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs @@ -1,6 +1,5 @@ using System; using System.Diagnostics; -using System.Globalization; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; @@ -219,7 +218,7 @@ namespace Umbraco.Web.Security if (rememberBrowser) { - var rememberBrowserIdentity = _authenticationManager.CreateTwoFactorRememberBrowserIdentity(ConvertIdToString(user.Id)); + var rememberBrowserIdentity = _authenticationManager.CreateTwoFactorRememberBrowserIdentity(user.Id.ToString()); _authenticationManager.SignIn(new AuthenticationProperties() { IsPersistent = isPersistent, @@ -263,14 +262,14 @@ namespace Umbraco.Web.Security /// /// Replaces the underlying call which is not flexible and doesn't support a custom cookie /// - public async Task GetVerifiedUserIdAsync() + public async Task GetVerifiedUserIdAsync() { var result = await _authenticationManager.AuthenticateAsync(Constants.Security.BackOfficeTwoFactorAuthenticationType); if (result != null && result.Identity != null && string.IsNullOrEmpty(result.Identity.GetUserId()) == false) { - return ConvertIdFromString(result.Identity.GetUserId()); + return result.Identity.GetUserId(); } - return int.MinValue; + return null; } /// @@ -304,11 +303,11 @@ namespace Umbraco.Web.Security public async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberBrowser) { var userId = await GetVerifiedUserIdAsync(); - if (userId == int.MinValue) + if (string.IsNullOrWhiteSpace(userId)) { return SignInResult.Failed; } - var user = await _userManager.FindByIdAsync(ConvertIdToString(userId)); + var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) { return SignInResult.Failed; @@ -339,29 +338,9 @@ namespace Umbraco.Web.Security /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate /// all of this code to check for int.MinVale instead. /// - public async Task SendTwoFactorCodeAsync(string provider) + public Task SendTwoFactorCodeAsync(string provider) { throw new NotImplementedException(); - - /*var userId = await GetVerifiedUserIdAsync(); - if (userId == int.MinValue) - return false; - - var token = await _userManager.GenerateTwoFactorTokenAsync(userId, provider); - - - var identityResult = await _userManager.NotifyTwoFactorTokenAsync(userId, provider, token); - return identityResult.Succeeded;*/ - } - - private string ConvertIdToString(int id) - { - return Convert.ToString(id, CultureInfo.InvariantCulture); - } - - private int ConvertIdFromString(string id) - { - return id == null ? default(int) : (int) Convert.ChangeType(id, typeof(int), CultureInfo.InvariantCulture); } public void Dispose() diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager.cs b/src/Umbraco.Web/Security/BackOfficeUserManager.cs index 805c6b902c..443485529c 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager.cs @@ -305,10 +305,7 @@ namespace Umbraco.Web.Security /// public async Task ChangePasswordWithResetAsync(int userId, string token, string newPassword) { - var userIdAsString = userId.TryConvertTo(); - if (!userIdAsString.Success) throw new InvalidOperationException("Unable to convert userId to int"); - - var user = await base.FindByIdAsync(userIdAsString.Result); + var user = await base.FindByIdAsync(userId.ToString()); var result = await base.ResetPasswordAsync(user, token, newPassword); if (result.Succeeded) RaisePasswordChangedEvent(userId); return result; diff --git a/src/Umbraco.Web/Security/BackOfficeUserStore.cs b/src/Umbraco.Web/Security/BackOfficeUserStore.cs index 6ddae55d82..feb8be9af1 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserStore.cs @@ -14,9 +14,6 @@ using Umbraco.Core.Models.Membership; using Umbraco.Core.Security; using Umbraco.Core.Services; using Umbraco.Web.Models.Identity; -using Constants = Umbraco.Core.Constants; -using IUser = Umbraco.Core.Models.Membership.IUser; -using UserLoginInfo = Microsoft.AspNetCore.Identity.UserLoginInfo; namespace Umbraco.Web.Security { @@ -70,7 +67,7 @@ namespace Umbraco.Web.Security ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - return Task.FromResult(UserIdToString(user.Id)); + return Task.FromResult(user.Id.ToString()); } public Task GetUserNameAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) @@ -895,16 +892,8 @@ namespace Umbraco.Web.Security return Task.FromResult(false); } - - private string UserIdToString(int userId) - { - var attempt = userId.TryConvertTo(); - if (attempt.Success) return attempt.Result; - - throw new InvalidOperationException("Unable to convert user ID to string", attempt.Exception); - } - - private int UserIdToInt(string userId) + + private static int UserIdToInt(string userId) { var attempt = userId.TryConvertTo(); if (attempt.Success) return attempt.Result; From 3584aa9fa32428f17c4fc795fe4cdfd2ae2a2e52 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Tue, 28 Apr 2020 13:00:02 +0100 Subject: [PATCH 24/34] Fixed naming for login info wrapper --- src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs | 6 +++--- src/Umbraco.Web/Security/BackOfficeUserStore.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs b/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs index 65d260b9c5..d9a8546c41 100644 --- a/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs +++ b/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs @@ -2,13 +2,13 @@ namespace Umbraco.Web.Models.Identity { - internal class UserLoginInfoWrapper2 : IUserLoginInfo + internal class UserLoginInfoWrapper : IUserLoginInfo { private readonly Microsoft.AspNetCore.Identity.UserLoginInfo _info; - public static IUserLoginInfo Wrap(Microsoft.AspNetCore.Identity.UserLoginInfo info) => new UserLoginInfoWrapper2(info); + public static IUserLoginInfo Wrap(Microsoft.AspNetCore.Identity.UserLoginInfo info) => new UserLoginInfoWrapper(info); - private UserLoginInfoWrapper2(Microsoft.AspNetCore.Identity.UserLoginInfo info) + private UserLoginInfoWrapper(Microsoft.AspNetCore.Identity.UserLoginInfo info) { _info = info; } diff --git a/src/Umbraco.Web/Security/BackOfficeUserStore.cs b/src/Umbraco.Web/Security/BackOfficeUserStore.cs index feb8be9af1..91e10c63d5 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserStore.cs @@ -167,7 +167,7 @@ namespace Umbraco.Web.Security if (isLoginsPropertyDirty) { var logins = await GetLoginsAsync(user); - _externalLoginService.SaveUserLogins(found.Id, logins.Select(UserLoginInfoWrapper2.Wrap)); + _externalLoginService.SaveUserLogins(found.Id, logins.Select(UserLoginInfoWrapper.Wrap)); } } @@ -440,7 +440,7 @@ namespace Umbraco.Web.Security ThrowIfDisposed(); //get all logins associated with the login id - var result = _externalLoginService.Find(UserLoginInfoWrapper2.Wrap(new UserLoginInfo(loginProvider, providerKey, loginProvider))).ToArray(); + var result = _externalLoginService.Find(UserLoginInfoWrapper.Wrap(new UserLoginInfo(loginProvider, providerKey, loginProvider))).ToArray(); if (result.Any()) { //return the first user that matches the result From d08bb08f5768f9d4a20c62cd8d54e6f4458ee618 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Tue, 28 Apr 2020 13:05:40 +0100 Subject: [PATCH 25/34] Cleanup --- src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs | 9 +++++---- src/Umbraco.Web/Security/BackOfficeUserStore.cs | 1 - 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs b/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs index d9a8546c41..336b4c9e72 100644 --- a/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs +++ b/src/Umbraco.Web/Models/Identity/UserLoginInfoWrapper.cs @@ -1,14 +1,15 @@ -using Umbraco.Core.Models.Identity; +using Microsoft.AspNetCore.Identity; +using Umbraco.Core.Models.Identity; namespace Umbraco.Web.Models.Identity { internal class UserLoginInfoWrapper : IUserLoginInfo { - private readonly Microsoft.AspNetCore.Identity.UserLoginInfo _info; + private readonly UserLoginInfo _info; - public static IUserLoginInfo Wrap(Microsoft.AspNetCore.Identity.UserLoginInfo info) => new UserLoginInfoWrapper(info); + public static IUserLoginInfo Wrap(UserLoginInfo info) => new UserLoginInfoWrapper(info); - private UserLoginInfoWrapper(Microsoft.AspNetCore.Identity.UserLoginInfo info) + private UserLoginInfoWrapper(UserLoginInfo info) { _info = info; } diff --git a/src/Umbraco.Web/Security/BackOfficeUserStore.cs b/src/Umbraco.Web/Security/BackOfficeUserStore.cs index 91e10c63d5..a62e9cc89f 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserStore.cs @@ -408,7 +408,6 @@ namespace Umbraco.Web.Security ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - // TODO: SCOTT: Consider adding display name to IIdentityUserLogin var userLogin = user.Logins.SingleOrDefault(l => l.LoginProvider == loginProvider && l.ProviderKey == providerKey); if (userLogin != null) user.Logins.Remove(userLogin); From 123b59506bd70c2248abfe8eeaf97af6844c6e07 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Thu, 30 Apr 2020 08:50:56 +0100 Subject: [PATCH 26/34] Apply suggestions from code review Co-Authored-By: Bjarke Berg --- src/Umbraco.Web/Security/AppBuilderExtensions.cs | 2 +- src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs | 2 +- src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs | 6 +----- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Web/Security/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/AppBuilderExtensions.cs index 9c929704e5..6865bd696b 100644 --- a/src/Umbraco.Web/Security/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/AppBuilderExtensions.cs @@ -430,7 +430,7 @@ namespace Umbraco.Web.Security { if (app == null) { - throw new ArgumentNullException("app"); + throw new ArgumentNullException(nameof(app)); } return app.CreatePerOwinContext(createCallback, (options, instance) => instance.Dispose()); } diff --git a/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs b/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs index ddd0703878..d091a51624 100644 --- a/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs +++ b/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs @@ -22,7 +22,7 @@ namespace Umbraco.Web.Security { if (options == null) { - throw new ArgumentNullException("options"); + throw new ArgumentNullException(nameof(options)); } if (options.Provider == null) { diff --git a/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs b/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs index a5f9c17765..1264b5f62e 100644 --- a/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs +++ b/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs @@ -24,11 +24,7 @@ namespace Umbraco.Web.Security return async context => { - var currentUtc = DateTimeOffset.UtcNow; - if (context.Options != null && context.Options.SystemClock != null) - { - currentUtc = context.Options.SystemClock.UtcNow; - } + var currentUtc = context.Options?.SystemClock?.UtcNow ?? DateTimeOffset.UtcNow; var issuedUtc = context.Properties.IssuedUtc; From 2caab18d293aa3af6b626b81fb0f41b8e1a4b70a Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Thu, 30 Apr 2020 09:27:34 +0100 Subject: [PATCH 27/34] Reintroduced FindFirstValue extension method --- .../ClaimsIdentityExtensionsTests.cs | 49 +++++++++++++++++++ src/Umbraco.Tests/Umbraco.Tests.csproj | 1 + src/Umbraco.Web/ClaimsIdentityExtensions.cs | 15 ++++-- .../Security/AuthenticationExtensions.cs | 2 +- .../AuthenticationManagerExtensions.cs | 2 +- .../BackOfficeCookieAuthenticationProvider.cs | 2 +- .../Security/SessionIdValidator.cs | 2 +- .../Security/UmbracoBackOfficeIdentity.cs | 13 ++--- .../Security/UmbracoSecurityStampValidator.cs | 2 +- 9 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 src/Umbraco.Tests/CoreThings/ClaimsIdentityExtensionsTests.cs diff --git a/src/Umbraco.Tests/CoreThings/ClaimsIdentityExtensionsTests.cs b/src/Umbraco.Tests/CoreThings/ClaimsIdentityExtensionsTests.cs new file mode 100644 index 0000000000..6cfd853179 --- /dev/null +++ b/src/Umbraco.Tests/CoreThings/ClaimsIdentityExtensionsTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using NUnit.Framework; +using Umbraco.Web; + +namespace Umbraco.Tests.CoreThings +{ + public class ClaimsIdentityExtensionsTests + { + [Test] + public void FindFirstValue_WhenIdentityIsNull_ExpectArgumentNullException() + { + ClaimsIdentity identity = null; + Assert.Throws(() => identity.FindFirstValue("test")); + } + + [Test] + public void FindFirstValue_WhenClaimNotPresent_ExpectNull() + { + var identity = new ClaimsIdentity(new List()); + var value = identity.FindFirstValue("test"); + Assert.IsNull(value); + } + + [Test] + public void FindFirstValue_WhenMatchingClaimPresent_ExpectCorrectValue() + { + var expectedClaim = new Claim("test", "123", "string", "Umbraco"); + var identity = new ClaimsIdentity(new List {expectedClaim}); + + var value = identity.FindFirstValue("test"); + + Assert.AreEqual(expectedClaim.Value, value); + } + + [Test] + public void FindFirstValue_WhenMultipleMatchingClaimsPresent_ExpectFirstValue() + { + var expectedClaim = new Claim("test", "123", "string", "Umbraco"); + var dupeClaim = new Claim(expectedClaim.Type, Guid.NewGuid().ToString()); + var identity = new ClaimsIdentity(new List {expectedClaim, dupeClaim}); + + var value = identity.FindFirstValue("test"); + + Assert.AreEqual(expectedClaim.Value, value); + } + } +} diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index c2ad177bf1..9221b6d3d6 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -124,6 +124,7 @@ + diff --git a/src/Umbraco.Web/ClaimsIdentityExtensions.cs b/src/Umbraco.Web/ClaimsIdentityExtensions.cs index c7f777f60b..733bbeed79 100644 --- a/src/Umbraco.Web/ClaimsIdentityExtensions.cs +++ b/src/Umbraco.Web/ClaimsIdentityExtensions.cs @@ -13,8 +13,8 @@ namespace Umbraco.Web string userId = null; if (identity is ClaimsIdentity claimsIdentity) { - userId = claimsIdentity.FindFirst(ClaimTypes.NameIdentifier)?.Value - ?? claimsIdentity.FindFirst("sub")?.Value; + userId = claimsIdentity.FindFirstValue(ClaimTypes.NameIdentifier) + ?? claimsIdentity.FindFirstValue("sub"); } return userId; @@ -27,11 +27,18 @@ namespace Umbraco.Web string username = null; if (identity is ClaimsIdentity claimsIdentity) { - username = claimsIdentity.FindFirst(ClaimTypes.Name)?.Value - ?? claimsIdentity.FindFirst("preferred_username")?.Value; + username = claimsIdentity.FindFirstValue(ClaimTypes.Name) + ?? claimsIdentity.FindFirstValue("preferred_username"); } return username; } + + public static string FindFirstValue(this ClaimsIdentity identity, string claimType) + { + if (identity == null) throw new ArgumentNullException(nameof(identity)); + + return identity.FindFirst(claimType)?.Value; + } } } diff --git a/src/Umbraco.Web/Security/AuthenticationExtensions.cs b/src/Umbraco.Web/Security/AuthenticationExtensions.cs index 07b39a12fe..04baa02c33 100644 --- a/src/Umbraco.Web/Security/AuthenticationExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationExtensions.cs @@ -230,7 +230,7 @@ namespace Umbraco.Web.Security var claimsIdentity = http.User.Identity as ClaimsIdentity; if (claimsIdentity != null) { - var sessionId = claimsIdentity.FindFirst(Constants.Security.SessionIdClaimType)?.Value; + var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); Guid guidSession; if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession)) { diff --git a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs index f809c402ae..8fc3f83def 100644 --- a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs @@ -28,7 +28,7 @@ namespace Umbraco.Web.Security name = name.Replace(" ", ""); } - var email = result.Identity.FindFirst(ClaimTypes.Email)?.Value; + var email = result.Identity.FindFirstValue(ClaimTypes.Email); return new ExternalLoginInfo { ExternalIdentity = result.Identity, diff --git a/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs b/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs index 87de1fe9c8..01f8dc4e96 100644 --- a/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs +++ b/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs @@ -57,7 +57,7 @@ namespace Umbraco.Web.Security if (context?.OwinContext?.Authentication?.User?.Identity != null) { var claimsIdentity = context.OwinContext.Authentication.User.Identity as ClaimsIdentity; - var sessionId = claimsIdentity.FindFirst(Core.Constants.Security.SessionIdClaimType)?.Value; + var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out var guidSession)) { _userService.ClearLoginSession(guidSession); diff --git a/src/Umbraco.Web/Security/SessionIdValidator.cs b/src/Umbraco.Web/Security/SessionIdValidator.cs index b58a59d358..9edae8da10 100644 --- a/src/Umbraco.Web/Security/SessionIdValidator.cs +++ b/src/Umbraco.Web/Security/SessionIdValidator.cs @@ -95,7 +95,7 @@ namespace Umbraco.Web.Security if (user == null) return false; - var sessionId = currentIdentity.FindFirst(Constants.Security.SessionIdClaimType)?.Value; + var sessionId = currentIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); if (await manager.ValidateSessionIdAsync(userId, sessionId) == false) return false; diff --git a/src/Umbraco.Web/Security/UmbracoBackOfficeIdentity.cs b/src/Umbraco.Web/Security/UmbracoBackOfficeIdentity.cs index e2f78546fd..c3697d5e9e 100644 --- a/src/Umbraco.Web/Security/UmbracoBackOfficeIdentity.cs +++ b/src/Umbraco.Web/Security/UmbracoBackOfficeIdentity.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Security.Claims; +using Umbraco.Web; namespace Umbraco.Core.Security { @@ -203,17 +204,17 @@ namespace Umbraco.Core.Security private string[] _allowedApplications; public string[] AllowedApplications => _allowedApplications ?? (_allowedApplications = FindAll(x => x.Type == Constants.Security.AllowedApplicationsClaimType).Select(app => app.Value).ToArray()); - public int Id => int.Parse(this.FindFirst(ClaimTypes.NameIdentifier)?.Value); + public int Id => int.Parse(this.FindFirstValue(ClaimTypes.NameIdentifier)); - public string RealName => this.FindFirst(ClaimTypes.GivenName)?.Value; + public string RealName => this.FindFirstValue(ClaimTypes.GivenName); - public string Username => this.FindFirst(ClaimTypes.Name)?.Value; + public string Username => this.FindFirstValue(ClaimTypes.Name); - public string Culture => this.FindFirst(ClaimTypes.Locality)?.Value; + public string Culture => this.FindFirstValue(ClaimTypes.Locality); public string SessionId { - get => this.FindFirst(Constants.Security.SessionIdClaimType)?.Value; + get => this.FindFirstValue(Constants.Security.SessionIdClaimType); set { var existing = FindFirst(Constants.Security.SessionIdClaimType); @@ -223,7 +224,7 @@ namespace Umbraco.Core.Security } } - public string SecurityStamp => this.FindFirst(Constants.Web.SecurityStampClaimType)?.Value; + public string SecurityStamp => this.FindFirstValue(Constants.Web.SecurityStampClaimType); public string[] Roles => this.FindAll(x => x.Type == DefaultRoleClaimType).Select(role => role.Value).ToArray(); diff --git a/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs b/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs index 1264b5f62e..a3f78f5262 100644 --- a/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs +++ b/src/Umbraco.Web/Security/UmbracoSecurityStampValidator.cs @@ -54,7 +54,7 @@ namespace Umbraco.Web.Security // Refresh the identity if the stamp matches, otherwise reject if (user != null && manager.SupportsUserSecurityStamp) { - var securityStamp = context.Identity.FindFirst(Constants.Web.SecurityStampClaimType)?.Value; + var securityStamp = context.Identity.FindFirstValue(Constants.Web.SecurityStampClaimType); var newSecurityStamp = await manager.GetSecurityStampAsync(user); if (securityStamp == newSecurityStamp) From 643c249386a2d3e9ac56ab279bc33ada7584c906 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Thu, 30 Apr 2020 09:28:46 +0100 Subject: [PATCH 28/34] Moved ClaimsIdentityExtensions into Umbraco.Core --- src/{Umbraco.Web => Umbraco.Core}/ClaimsIdentityExtensions.cs | 2 +- src/Umbraco.Tests/CoreThings/ClaimsIdentityExtensionsTests.cs | 1 + src/Umbraco.Web/Umbraco.Web.csproj | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) rename src/{Umbraco.Web => Umbraco.Core}/ClaimsIdentityExtensions.cs (98%) diff --git a/src/Umbraco.Web/ClaimsIdentityExtensions.cs b/src/Umbraco.Core/ClaimsIdentityExtensions.cs similarity index 98% rename from src/Umbraco.Web/ClaimsIdentityExtensions.cs rename to src/Umbraco.Core/ClaimsIdentityExtensions.cs index 733bbeed79..53fde97312 100644 --- a/src/Umbraco.Web/ClaimsIdentityExtensions.cs +++ b/src/Umbraco.Core/ClaimsIdentityExtensions.cs @@ -2,7 +2,7 @@ using System.Security.Claims; using System.Security.Principal; -namespace Umbraco.Web +namespace Umbraco.Core { public static class ClaimsIdentityExtensions { diff --git a/src/Umbraco.Tests/CoreThings/ClaimsIdentityExtensionsTests.cs b/src/Umbraco.Tests/CoreThings/ClaimsIdentityExtensionsTests.cs index 6cfd853179..4f728c3861 100644 --- a/src/Umbraco.Tests/CoreThings/ClaimsIdentityExtensionsTests.cs +++ b/src/Umbraco.Tests/CoreThings/ClaimsIdentityExtensionsTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Security.Claims; using NUnit.Framework; +using Umbraco.Core; using Umbraco.Web; namespace Umbraco.Tests.CoreThings diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 061893da74..88833c38de 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -139,7 +139,6 @@ - From 077cf57abc88b832ff59633faa1ad04e82f6e12e Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Thu, 30 Apr 2020 15:48:32 +0100 Subject: [PATCH 29/34] PR recommendations: added null checks where FindByX was added, using the same exception as ASP.NET Identity 2 --- .../Editors/AuthenticationController.cs | 1 + .../Editors/BackOfficeController.cs | 1 + .../Editors/CurrentUserController.cs | 2 ++ .../Security/BackOfficeUserManager.cs | 21 +++---------------- .../Security/IdentityFactoryMiddleware.cs | 17 +++------------ 5 files changed, 10 insertions(+), 32 deletions(-) diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index a0143548fc..c7b22fc02f 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -131,6 +131,7 @@ namespace Umbraco.Web.Editors public async Task PostUnLinkLogin(UnLinkLoginModel unlinkLoginModel) { var user = await UserManager.FindByIdAsync(User.Identity.GetUserId()); + if (user == null) throw new InvalidOperationException("Could not find user"); var result = await UserManager.RemoveLoginAsync( user, diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index 34bb2e6e03..307c826035 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -329,6 +329,7 @@ namespace Umbraco.Web.Editors } var user = await UserManager.FindByIdAsync(User.Identity.GetUserId()); + if (user == null) throw new InvalidOperationException("Could not find user"); var result = await UserManager.AddLoginAsync(user, new UserLoginInfo(loginInfo.Login.LoginProvider, loginInfo.Login.ProviderKey, loginInfo.Login.LoginProvider)); diff --git a/src/Umbraco.Web/Editors/CurrentUserController.cs b/src/Umbraco.Web/Editors/CurrentUserController.cs index 678b6e098d..ad07155bc0 100644 --- a/src/Umbraco.Web/Editors/CurrentUserController.cs +++ b/src/Umbraco.Web/Editors/CurrentUserController.cs @@ -159,6 +159,8 @@ namespace Umbraco.Web.Editors public async Task PostSetInvitedUserPassword([FromBody]string newPassword) { var user = await UserManager.FindByIdAsync(Security.GetUserId().ResultOr(0).ToString()); + if (user == null) throw new InvalidOperationException("Could not find user"); + var result = await UserManager.AddPasswordAsync(user, newPassword); if (result.Succeeded == false) diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager.cs b/src/Umbraco.Web/Security/BackOfficeUserManager.cs index 443485529c..a143029327 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserManager.cs @@ -306,6 +306,8 @@ namespace Umbraco.Web.Security public async Task ChangePasswordWithResetAsync(int userId, string token, string newPassword) { var user = await base.FindByIdAsync(userId.ToString()); + if (user == null) throw new InvalidOperationException("Could not find user"); + var result = await base.ResetPasswordAsync(user, token, newPassword); if (result.Succeeded) RaisePasswordChangedEvent(userId); return result; @@ -317,24 +319,7 @@ namespace Umbraco.Web.Security if (result.Succeeded) RaisePasswordChangedEvent(user.Id); return result; } - - /// - /// Override to determine how to hash the password - /// - /// - /// - /// - /// - protected override async Task VerifyPasswordAsync(IUserPasswordStore store, T user, string password) - { - var userAwarePasswordHasher = PasswordHasher; - if (userAwarePasswordHasher == null) - return await base.VerifyPasswordAsync(store, user, password); - - var hash = await store.GetPasswordHashAsync(user, CancellationToken.None); - return userAwarePasswordHasher.VerifyHashedPassword(user, hash, password); - } - + /// /// Override to determine how to hash the password /// diff --git a/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs b/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs index d091a51624..2975149107 100644 --- a/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs +++ b/src/Umbraco.Web/Security/IdentityFactoryMiddleware.cs @@ -12,22 +12,14 @@ namespace Umbraco.Web.Security where TResult : class, IDisposable where TOptions : IdentityFactoryOptions { - /// - /// Constructor - /// /// The next middleware in the OWIN pipeline to invoke /// Configuration options for the middleware public IdentityFactoryMiddleware(OwinMiddleware next, TOptions options) : base(next) { - if (options == null) - { - throw new ArgumentNullException(nameof(options)); - } - if (options.Provider == null) - { - throw new ArgumentNullException("options.Provider"); - } + if (options == null) throw new ArgumentNullException(nameof(options)); + if (options.Provider == null) throw new ArgumentException("options.Provider"); + Options = options; } @@ -74,9 +66,6 @@ namespace Umbraco.Web.Security public class IdentityFactoryProvider where T : class, IDisposable { - /// - /// Constructor - /// public IdentityFactoryProvider() { OnDispose = (options, instance) => { }; From f3f4537c584472908e7a583e0d339b604cf164d1 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Thu, 30 Apr 2020 15:50:19 +0100 Subject: [PATCH 30/34] Removed test settings --- src/Umbraco.Web/Security/AppBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web/Security/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/AppBuilderExtensions.cs index 6865bd696b..3f129f941d 100644 --- a/src/Umbraco.Web/Security/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/AppBuilderExtensions.cs @@ -154,7 +154,7 @@ namespace Umbraco.Web.Security // change a password or add an external login to your account. OnValidateIdentity = UmbracoSecurityStampValidator .OnValidateIdentity( - TimeSpan.FromMinutes(3), + TimeSpan.FromMinutes(30), (signInManager, manager, user) => signInManager.CreateUserIdentityAsync(user), identity => identity.GetUserId()), From 57ec84cb47b9def11aa97fab5c7bc54eb67a44c5 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Mon, 4 May 2020 18:19:24 +0100 Subject: [PATCH 31/34] Basic tests & refactor for PostUnlockUsers --- .../Web/Controllers/UsersControllerTests.cs | 204 +++++++++++++++++- src/Umbraco.Web/Editors/UsersController.cs | 33 ++- 2 files changed, 208 insertions(+), 29 deletions(-) diff --git a/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs b/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs index 563d9b80f8..4c373b2bc8 100644 --- a/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs +++ b/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs @@ -1,12 +1,19 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Formatting; using System.Reflection; using System.Security.Cryptography; +using System.Threading.Tasks; using System.Web.Http; +using System.Web.Http.Controllers; +using System.Web.Http.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.Owin; using Moq; using Newtonsoft.Json; using NUnit.Framework; @@ -19,12 +26,10 @@ using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Membership; using Umbraco.Core.Persistence; -using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Persistence.Mappers; using Umbraco.Core.Persistence.Querying; using Umbraco.Core.Persistence.SqlSyntax; using Umbraco.Core.Services; -using Umbraco.Core.Strings; using Umbraco.Tests.TestHelpers; using Umbraco.Tests.TestHelpers.ControllerTesting; using Umbraco.Tests.TestHelpers.Entities; @@ -39,6 +44,9 @@ using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Hosting; using Umbraco.Web.Routing; using Umbraco.Core.Media; +using Umbraco.Net; +using Umbraco.Web.Models.Identity; +using Umbraco.Web.Security; namespace Umbraco.Tests.Web.Controllers { @@ -62,7 +70,7 @@ namespace Umbraco.Tests.Web.Controllers } [Test] - public async System.Threading.Tasks.Task Save_User() + public async Task Save_User() { ApiController CtrlFactory(HttpRequestMessage message, IUmbracoContextAccessor umbracoContextAccessor) { @@ -149,7 +157,7 @@ namespace Umbraco.Tests.Web.Controllers } [Test] - public async System.Threading.Tasks.Task GetPagedUsers_Empty() + public async Task GetPagedUsers_Empty() { ApiController CtrlFactory(HttpRequestMessage message, IUmbracoContextAccessor umbracoContextAccessor) { @@ -183,7 +191,7 @@ namespace Umbraco.Tests.Web.Controllers } [Test] - public async System.Threading.Tasks.Task GetPagedUsers_10() + public async Task GetPagedUsers_10() { ApiController CtrlFactory(HttpRequestMessage message, IUmbracoContextAccessor umbracoContextAccessor) { @@ -227,7 +235,7 @@ namespace Umbraco.Tests.Web.Controllers } [Test] - public async System.Threading.Tasks.Task GetPagedUsers_Fips() + public async Task GetPagedUsers_Fips() { await RunFipsTest("GetPagedUsers", mock => { @@ -246,7 +254,7 @@ namespace Umbraco.Tests.Web.Controllers } [Test] - public async System.Threading.Tasks.Task GetById_Fips() + public async Task GetById_Fips() { const int mockUserId = 1234; var user = MockedUser.CreateUser(); @@ -264,7 +272,7 @@ namespace Umbraco.Tests.Web.Controllers } - private async System.Threading.Tasks.Task RunFipsTest(string action, Action> userServiceSetup, + private async Task RunFipsTest(string action, Action> userServiceSetup, Action> verification, object routeDefaults = null, string url = null) { @@ -324,5 +332,185 @@ namespace Umbraco.Tests.Web.Controllers } } } + + [Test] + public async Task PostUnlockUsers_When_UserIds_Not_Supplied_Expect_Ok_Response() + { + var usersController = CreateSut(); + + usersController.Request = new HttpRequestMessage(); + + var response = await usersController.PostUnlockUsers(new int[0]); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + } + + [Test] + public void PostUnlockUsers_When_User_Does_Not_Exist_Expect_InvalidOperationException() + { + var mockUserManager = CreateMockUserManager(); + var usersController = CreateSut(mockUserManager); + + mockUserManager.Setup(x => x.FindByIdAsync(It.IsAny())) + .ReturnsAsync((BackOfficeIdentityUser) null); + + Assert.ThrowsAsync(async () => await usersController.PostUnlockUsers(new[] {1})); + } + + [Test] + public async Task PostUnlockUsers_When_User_Lockout_Update_Fails_Expect_Failure_Response() + { + var mockUserManager = CreateMockUserManager(); + var usersController = CreateSut(mockUserManager); + + const string expectedMessage = "identity error!"; + var user = new BackOfficeIdentityUser( + new Mock().Object, + 1, + new List()) + { + Name = "bob" + }; + + mockUserManager.Setup(x => x.FindByIdAsync(It.IsAny())) + .ReturnsAsync(user); + mockUserManager.Setup(x => x.SetLockoutEndDateAsync(user, It.IsAny())) + .ReturnsAsync(IdentityResult.Failed(new IdentityError {Description = expectedMessage})); + + var response = await usersController.PostUnlockUsers(new[] { 1 }); + + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + Assert.True(response.Headers.TryGetValues("X-Status-Reason", out var values)); + Assert.True(values.Contains("Validation failed")); + + var responseContent = response.Content as ObjectContent; + var responseValue = responseContent?.Value as HttpError; + Assert.NotNull(responseValue); + Assert.True(responseValue.Message.Contains(expectedMessage)); + Assert.True(responseValue.Message.Contains(user.Id.ToString())); + } + + [Test] + public async Task PostUnlockUsers_When_One_UserId_Supplied_Expect_User_Locked_Out_With_Correct_Response_Message() + { + var mockUserManager = CreateMockUserManager(); + var usersController = CreateSut(mockUserManager); + + var user = new BackOfficeIdentityUser( + new Mock().Object, + 1, + new List()) + { + Name = "bob" + }; + + mockUserManager.Setup(x => x.FindByIdAsync(user.Id.ToString())) + .ReturnsAsync(user); + mockUserManager.Setup(x => x.SetLockoutEndDateAsync(user, It.IsAny())) + .ReturnsAsync(IdentityResult.Success) + .Verifiable(); + + var response = await usersController.PostUnlockUsers(new[] { user.Id }); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + var responseContent = response.Content as ObjectContent; + var notifications = responseContent?.Value as SimpleNotificationModel; + Assert.NotNull(notifications); + Assert.AreEqual(user.Name, notifications.Message); + mockUserManager.Verify(); + } + + [Test] + public async Task PostUnlockUsers_When_Multiple_UserIds_Supplied_Expect_User_Locked_Out_With_Correct_Response_Message() + { + var mockUserManager = CreateMockUserManager(); + var usersController = CreateSut(mockUserManager); + + var user1 = new BackOfficeIdentityUser( + new Mock().Object, + 1, + new List()) + { + Name = "bob" + }; + var user2 = new BackOfficeIdentityUser( + new Mock().Object, + 2, + new List()) + { + Name = "alice" + }; + var userIdsToLock = new[] {user1.Id, user2.Id}; + + mockUserManager.Setup(x => x.FindByIdAsync(user1.Id.ToString())) + .ReturnsAsync(user1); + mockUserManager.Setup(x => x.FindByIdAsync(user2.Id.ToString())) + .ReturnsAsync(user2); + mockUserManager.Setup(x => x.SetLockoutEndDateAsync(user1, It.IsAny())) + .ReturnsAsync(IdentityResult.Success) + .Verifiable(); + mockUserManager.Setup(x => x.SetLockoutEndDateAsync(user2, It.IsAny())) + .ReturnsAsync(IdentityResult.Success) + .Verifiable(); + + var response = await usersController.PostUnlockUsers(userIdsToLock); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + var responseContent = response.Content as ObjectContent; + var notifications = responseContent?.Value as SimpleNotificationModel; + Assert.NotNull(notifications); + Assert.AreEqual(userIdsToLock.Length.ToString(), notifications.Message); + mockUserManager.Verify(); + } + + private UsersController CreateSut(IMock> mockUserManager = null) + { + var mockLocalizedTextService = new Mock(); + mockLocalizedTextService.Setup(x => x.Localize(It.IsAny(), It.IsAny(), It.IsAny>())) + .Returns((string key, CultureInfo ci, IDictionary tokens) + => tokens.Aggregate("", (current, next) => current + (current == string.Empty ? "" : ",") + next.Value)); + + var usersController = new UsersController( + Factory.GetInstance(), + Factory.GetInstance(), + Factory.GetInstance(), + ServiceContext.CreatePartial(localizedTextService: mockLocalizedTextService.Object), + Factory.GetInstance(), + Factory.GetInstance(), + Factory.GetInstance(), + Factory.GetInstance(), + ShortStringHelper, + Factory.GetInstance(), + Factory.GetInstance(), + Factory.GetInstance(), + Factory.GetInstance(), + Factory.GetInstance(), + Factory.GetInstance()); + + var mockOwinContext = new Mock(); + var mockUserManagerMarker = new Mock(); + + mockOwinContext.Setup(x => x.Get(It.IsAny())) + .Returns(mockUserManagerMarker.Object); + mockUserManagerMarker.Setup(x => x.GetManager(It.IsAny())) + .Returns(mockUserManager?.Object ?? CreateMockUserManager().Object); + + usersController.Request = new HttpRequestMessage(); + usersController.Request.Properties["MS_OwinContext"] = mockOwinContext.Object; + usersController.Request.Properties[HttpPropertyKeys.RequestContextKey] = new HttpRequestContext {Configuration = new HttpConfiguration()}; + + return usersController; + } + + private static Mock> CreateMockUserManager() + { + return new Mock>( + new Mock().Object, + new Mock().Object, + new Mock>().Object, + null, null, null, null, null, null, null); + } } } diff --git a/src/Umbraco.Web/Editors/UsersController.cs b/src/Umbraco.Web/Editors/UsersController.cs index 8e73fa1d02..e58cf686ee 100644 --- a/src/Umbraco.Web/Editors/UsersController.cs +++ b/src/Umbraco.Web/Editors/UsersController.cs @@ -701,38 +701,29 @@ namespace Umbraco.Web.Editors [AdminUsersAuthorize("userIds")] public async Task PostUnlockUsers([FromUri]int[] userIds) { - if (userIds.Length <= 0) - return Request.CreateResponse(HttpStatusCode.OK); - - var user = await UserManager.FindByIdAsync(userIds[0].ToString()); - - if (userIds.Length == 1) - { - - - var unlockResult = await UserManager.SetLockoutEndDateAsync(user, DateTimeOffset.Now); - if (unlockResult.Succeeded == false) - { - return Request.CreateValidationErrorResponse( - string.Format("Could not unlock for user {0} - error {1}", userIds[0], unlockResult.Errors.First())); - } - - return Request.CreateNotificationSuccessResponse( - Services.TextService.Localize("speechBubbles/unlockUserSuccess", new[] { user.Name })); - } + if (userIds.Length <= 0) return Request.CreateResponse(HttpStatusCode.OK); foreach (var u in userIds) { + var user = await UserManager.FindByIdAsync(u.ToString()); + if (user == null) throw new InvalidOperationException(); + var unlockResult = await UserManager.SetLockoutEndDateAsync(user, DateTimeOffset.Now); if (unlockResult.Succeeded == false) { return Request.CreateValidationErrorResponse( - string.Format("Could not unlock for user {0} - error {1}", u, unlockResult.Errors.First())); + string.Format("Could not unlock for user {0} - error {1}", u, unlockResult.Errors.First().Description)); + } + + if (userIds.Length == 1) + { + return Request.CreateNotificationSuccessResponse( + Services.TextService.Localize("speechBubbles/unlockUserSuccess", new[] {user.Name})); } } return Request.CreateNotificationSuccessResponse( - Services.TextService.Localize("speechBubbles/unlockUsersSuccess", new[] { userIds.Length.ToString() })); + Services.TextService.Localize("speechBubbles/unlockUsersSuccess", new[] {userIds.Length.ToString()})); } [AdminUsersAuthorize("userIds")] From 04e91404862e296ab5d5b6cf98c1c11394912cae Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Mon, 4 May 2020 18:32:57 +0100 Subject: [PATCH 32/34] Null checks and property uses --- src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs | 5 ++++- .../Security/AuthenticationManagerExtensions.cs | 7 ++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs b/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs index c5601a2451..6f86b5b072 100644 --- a/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs +++ b/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Specialized; using System.Net.Http; using System.Text; @@ -65,6 +65,9 @@ namespace Umbraco.Web.Install.InstallSteps //To change the password here we actually need to reset it since we don't have an old one to use to change var resetToken = await userManager.GeneratePasswordResetTokenAsync(membershipUser); + if (string.IsNullOrWhiteSpace(resetToken)) + throw new InvalidOperationException("Could not reset password: unable to generate internal reset token"); + var resetResult = await userManager.ChangePasswordWithResetAsync(membershipUser.Id, resetToken, user.Password.Trim()); if (!resetResult.Succeeded) { diff --git a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs index 8fc3f83def..6ce1bbc86b 100644 --- a/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationManagerExtensions.cs @@ -78,17 +78,14 @@ namespace Umbraco.Web.Security /// public static async Task GetExternalLoginInfoAsync(this IAuthenticationManager manager, string authenticationType) { - if (manager == null) - { - throw new ArgumentNullException("manager"); - } + if (manager == null) throw new ArgumentNullException(nameof(manager)); return GetExternalLoginInfo(await manager.AuthenticateAsync(authenticationType)); } public static IEnumerable GetExternalAuthenticationTypes(this IAuthenticationManager manager) { if (manager == null) throw new ArgumentNullException(nameof(manager)); - return manager.GetAuthenticationTypes(d => d.Properties != null && d.Properties.ContainsKey("Caption")); + return manager.GetAuthenticationTypes(d => d.Properties != null && d.Caption != null); } public static ClaimsIdentity CreateTwoFactorRememberBrowserIdentity(this IAuthenticationManager manager, string userId) From c58f480d24d82883e004c312161c20b3effd9b54 Mon Sep 17 00:00:00 2001 From: Scott Brady Date: Mon, 4 May 2020 18:49:02 +0100 Subject: [PATCH 33/34] Addressed IdentityError changes (it's no longer a collection of string) --- .../Security/AuthenticationExtensionsTests.cs | 46 +++++++++++++++++++ src/Umbraco.Tests/Umbraco.Tests.csproj | 1 + .../Editors/AuthenticationController.cs | 6 +-- .../Editors/BackOfficeController.cs | 2 +- .../Editors/CurrentUserController.cs | 4 +- src/Umbraco.Web/Editors/PasswordChanger.cs | 4 +- src/Umbraco.Web/Editors/UsersController.cs | 9 ++-- .../Install/InstallSteps/NewInstallStep.cs | 8 ++-- .../Security/AuthenticationExtensions.cs | 7 +++ 9 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 src/Umbraco.Tests/Security/AuthenticationExtensionsTests.cs diff --git a/src/Umbraco.Tests/Security/AuthenticationExtensionsTests.cs b/src/Umbraco.Tests/Security/AuthenticationExtensionsTests.cs new file mode 100644 index 0000000000..e622458c39 --- /dev/null +++ b/src/Umbraco.Tests/Security/AuthenticationExtensionsTests.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Identity; +using NUnit.Framework; +using Umbraco.Web.Security; + +namespace Umbraco.Tests.Security +{ + public class AuthenticationExtensionsTests + { + [Test] + public void ToErrorMessage_When_Errors_Are_Null_Expect_ArgumentNullException() + { + IEnumerable errors = null; + + Assert.Throws(() => errors.ToErrorMessage()); + } + + [Test] + public void ToErrorMessage_When_Single_Error_Expect_Error_Description() + { + const string expectedError = "invalid something"; + var errors = new List {new IdentityError {Code = "1", Description = expectedError}}; + + var errorMessage = errors.ToErrorMessage(); + + Assert.AreEqual(expectedError, errorMessage); + } + + [Test] + public void ToErrorMessage_When_Multiple_Errors_Expect_Error_Descriptions_With_Comma_Delimiter() + { + const string error1 = "invalid something"; + const string error2 = "invalid something else"; + var errors = new List + { + new IdentityError {Code = "1", Description = error1}, + new IdentityError {Code = "2", Description = error2} + }; + + var errorMessage = errors.ToErrorMessage(); + + Assert.AreEqual($"{error1}, {error2}", errorMessage); + } + } +} diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index 17b43cf5a9..5227182be6 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -146,6 +146,7 @@ + diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index c7b22fc02f..fc34a35566 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -112,7 +112,7 @@ namespace Umbraco.Web.Editors if (result.Succeeded == false) { - throw new HttpResponseException(Request.CreateNotificationValidationErrorResponse(string.Join(", ", result.Errors))); + throw new HttpResponseException(Request.CreateNotificationValidationErrorResponse(result.Errors.ToErrorMessage())); } Request.TryGetOwinContext().Result.Authentication.SignOut( @@ -438,13 +438,13 @@ namespace Umbraco.Web.Editors var unlockResult = await UserManager.SetLockoutEndDateAsync(identityUser, DateTimeOffset.Now); if (unlockResult.Succeeded == false) { - Logger.Warn("Could not unlock for user {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First()); + Logger.Warn("Could not unlock for user {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First().Description); } var resetAccessFailedCountResult = await UserManager.ResetAccessFailedCountAsync(identityUser); if (resetAccessFailedCountResult.Succeeded == false) { - Logger.Warn("Could not reset access failed count {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First()); + Logger.Warn("Could not reset access failed count {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First().Description); } } diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index 307c826035..7e3638d96a 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -159,7 +159,7 @@ namespace Umbraco.Web.Editors if (result.Succeeded == false) { - Logger.Warn("Could not verify email, Error: {Errors}, Token: {Invite}", string.Join(",", result.Errors), invite); + Logger.Warn("Could not verify email, Error: {Errors}, Token: {Invite}", result.Errors.ToErrorMessage(), invite); return new RedirectResult(Url.Action("Default") + "#/login/false?invite=3"); } diff --git a/src/Umbraco.Web/Editors/CurrentUserController.cs b/src/Umbraco.Web/Editors/CurrentUserController.cs index ad07155bc0..2ad09dc895 100644 --- a/src/Umbraco.Web/Editors/CurrentUserController.cs +++ b/src/Umbraco.Web/Editors/CurrentUserController.cs @@ -167,9 +167,7 @@ namespace Umbraco.Web.Editors { //it wasn't successful, so add the change error to the model state, we've name the property alias _umb_password on the form // so that is why it is being used here. - ModelState.AddModelError( - "value", - string.Join(", ", result.Errors)); + ModelState.AddModelError("value", result.Errors.ToErrorMessage()); throw new HttpResponseException(Request.CreateValidationErrorResponse(ModelState)); } diff --git a/src/Umbraco.Web/Editors/PasswordChanger.cs b/src/Umbraco.Web/Editors/PasswordChanger.cs index 867856f494..b6757f76cc 100644 --- a/src/Umbraco.Web/Editors/PasswordChanger.cs +++ b/src/Umbraco.Web/Editors/PasswordChanger.cs @@ -73,7 +73,7 @@ namespace Umbraco.Web.Editors if (resetResult.Succeeded == false) { - var errors = string.Join(". ", resetResult.Errors); + var errors = resetResult.Errors.ToErrorMessage(); _logger.Warn("Could not reset user password {PasswordErrors}", errors); return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult(errors, new[] { "value" }) }); } @@ -93,7 +93,7 @@ namespace Umbraco.Web.Editors if (changeResult.Succeeded == false) { //no, fail with error messages for "password" - var errors = string.Join(". ", changeResult.Errors); + var errors = changeResult.Errors.ToErrorMessage(); _logger.Warn("Could not change user password {PasswordErrors}", errors); return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult(errors, new[] { "password" }) }); } diff --git a/src/Umbraco.Web/Editors/UsersController.cs b/src/Umbraco.Web/Editors/UsersController.cs index e58cf686ee..7aa71b6e2e 100644 --- a/src/Umbraco.Web/Editors/UsersController.cs +++ b/src/Umbraco.Web/Editors/UsersController.cs @@ -36,6 +36,7 @@ using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Hosting; using Umbraco.Web.Routing; using Umbraco.Core.Media; +using Umbraco.Web.Security; namespace Umbraco.Web.Editors { @@ -320,7 +321,7 @@ namespace Umbraco.Web.Editors if (created.Succeeded == false) { throw new HttpResponseException( - Request.CreateNotificationValidationErrorResponse(string.Join(", ", created.Errors))); + Request.CreateNotificationValidationErrorResponse(created.Errors.ToErrorMessage())); } string resetPassword; @@ -330,7 +331,7 @@ namespace Umbraco.Web.Editors if (result.Succeeded == false) { throw new HttpResponseException( - Request.CreateNotificationValidationErrorResponse(string.Join(", ", created.Errors))); + Request.CreateNotificationValidationErrorResponse(created.Errors.ToErrorMessage())); } resetPassword = password; @@ -409,7 +410,7 @@ namespace Umbraco.Web.Editors if (created.Succeeded == false) { throw new HttpResponseException( - Request.CreateNotificationValidationErrorResponse(string.Join(", ", created.Errors))); + Request.CreateNotificationValidationErrorResponse(created.Errors.ToErrorMessage())); } //now re-look the user back up @@ -712,7 +713,7 @@ namespace Umbraco.Web.Editors if (unlockResult.Succeeded == false) { return Request.CreateValidationErrorResponse( - string.Format("Could not unlock for user {0} - error {1}", u, unlockResult.Errors.First().Description)); + string.Format("Could not unlock for user {0} - error {1}", u, unlockResult.Errors.ToErrorMessage())); } if (userIds.Length == 1) diff --git a/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs b/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs index 6f86b5b072..b83d0b3a93 100644 --- a/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs +++ b/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Specialized; +using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; @@ -11,6 +12,7 @@ using Umbraco.Core.Migrations.Install; using Umbraco.Core.Services; using Umbraco.Web.Install.Models; using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Web.Security; namespace Umbraco.Web.Install.InstallSteps { @@ -70,9 +72,7 @@ namespace Umbraco.Web.Install.InstallSteps var resetResult = await userManager.ChangePasswordWithResetAsync(membershipUser.Id, resetToken, user.Password.Trim()); if (!resetResult.Succeeded) - { - throw new InvalidOperationException("Could not reset password: " + string.Join(", ", resetResult.Errors)); - } + throw new InvalidOperationException("Could not reset password: " + string.Join(", ", resetResult.Errors.ToErrorMessage())); admin.Email = user.Email.Trim(); admin.Name = user.Name.Trim(); diff --git a/src/Umbraco.Web/Security/AuthenticationExtensions.cs b/src/Umbraco.Web/Security/AuthenticationExtensions.cs index 04baa02c33..7d76eaa7be 100644 --- a/src/Umbraco.Web/Security/AuthenticationExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationExtensions.cs @@ -7,6 +7,7 @@ using System.Security.Claims; using System.Security.Principal; using System.Threading; using System.Web; +using Microsoft.AspNetCore.Identity; using Microsoft.Owin; using Microsoft.Owin.Security; using Newtonsoft.Json; @@ -383,5 +384,11 @@ namespace Umbraco.Web.Security /// private static readonly ConcurrentDictionary UserCultures = new ConcurrentDictionary(); + public static string ToErrorMessage(this IEnumerable errors) + { + if (errors == null) throw new ArgumentNullException(nameof(errors)); + return string.Join(", ", errors.Select(x => x.Description).ToList()); + } + } } From e8fc2a766b66e9ac0e1399f5fcf6f3b9d9367231 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 5 May 2020 11:33:19 +0200 Subject: [PATCH 34/34] Implemented GetUsersInRoleAsync and reintroduced email check --- .../Install/InstallSteps/NewInstallStep.cs | 9 +++--- .../Security/BackOfficeUserStore.cs | 31 ++++++++++++++----- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs b/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs index b83d0b3a93..fedb93d6f1 100644 --- a/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs +++ b/src/Umbraco.Web/Install/InstallSteps/NewInstallStep.cs @@ -56,6 +56,11 @@ namespace Umbraco.Web.Install.InstallSteps { throw new InvalidOperationException("Could not find the super user!"); } + admin.Email = user.Email.Trim(); + admin.Name = user.Name.Trim(); + admin.Username = user.Email.Trim(); + + _userService.Save(admin); var userManager = _httpContextAccessor.GetRequiredHttpContext().GetOwinContext().GetBackOfficeUserManager(); var membershipUser = await userManager.FindByIdAsync(Constants.Security.SuperUserId.ToString()); @@ -74,11 +79,7 @@ namespace Umbraco.Web.Install.InstallSteps if (!resetResult.Succeeded) throw new InvalidOperationException("Could not reset password: " + string.Join(", ", resetResult.Errors.ToErrorMessage())); - admin.Email = user.Email.Trim(); - admin.Name = user.Name.Trim(); - admin.Username = user.Email.Trim(); - _userService.Save(admin); if (user.SubscribeToNewsLetter) { diff --git a/src/Umbraco.Web/Security/BackOfficeUserStore.cs b/src/Umbraco.Web/Security/BackOfficeUserStore.cs index a62e9cc89f..6f401163aa 100644 --- a/src/Umbraco.Web/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Web/Security/BackOfficeUserStore.cs @@ -66,7 +66,7 @@ namespace Umbraco.Web.Security cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - + return Task.FromResult(user.Id.ToString()); } @@ -152,7 +152,7 @@ namespace Umbraco.Web.Security cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - + var found = _userService.GetUserById(user.Id); if (found != null) { @@ -184,7 +184,7 @@ namespace Umbraco.Web.Security cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - + var found = _userService.GetUserById(user.Id); if (found != null) { @@ -293,7 +293,7 @@ namespace Umbraco.Web.Security cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - // if (email.IsNullOrWhiteSpace()) throw new ArgumentNullException("email"); + if (email.IsNullOrWhiteSpace()) throw new ArgumentNullException(nameof(email)); user.Email = email; @@ -540,9 +540,24 @@ namespace Umbraco.Web.Security return Task.FromResult(user.Roles.Select(x => x.RoleId).InvariantContains(normalizedRoleName)); } - public Task> GetUsersInRoleAsync(string normalizedRoleName, CancellationToken cancellationToken) + /// + /// Lists all users of a given role. + /// + /// + /// Identity Role names are equal to Umbraco UserGroup alias. + /// + public Task> GetUsersInRoleAsync(string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)) { - throw new NotImplementedException(); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (normalizedRoleName == null) throw new ArgumentNullException(nameof(normalizedRoleName)); + + var userGroup = _userService.GetUserGroupByAlias(normalizedRoleName); + + var users = _userService.GetAllInGroup(userGroup.Id); + IList backOfficeIdentityUsers = users.Select(x => _mapper.Map(x)).ToList(); + + return Task.FromResult(backOfficeIdentityUsers); } /// @@ -875,7 +890,7 @@ namespace Umbraco.Web.Security return anythingChanged; } - + private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(GetType().Name); @@ -891,7 +906,7 @@ namespace Umbraco.Web.Security return Task.FromResult(false); } - + private static int UserIdToInt(string userId) { var attempt = userId.TryConvertTo();