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()); } }