diff --git a/linting/codeanalysis.ruleset b/linting/codeanalysis.ruleset index 4fde2bef8d..57c9fb7d60 100644 --- a/linting/codeanalysis.ruleset +++ b/linting/codeanalysis.ruleset @@ -11,6 +11,8 @@ + + \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Identity/IdentityUser.cs b/src/Umbraco.Core/Models/Identity/UmbracoIdentityUser.cs similarity index 98% rename from src/Umbraco.Core/Models/Identity/IdentityUser.cs rename to src/Umbraco.Core/Models/Identity/UmbracoIdentityUser.cs index 516bd60c49..ffa549ab47 100644 --- a/src/Umbraco.Core/Models/Identity/IdentityUser.cs +++ b/src/Umbraco.Core/Models/Identity/UmbracoIdentityUser.cs @@ -17,7 +17,7 @@ namespace Umbraco.Core.Models.Identity /// to a user. We will continue using this approach since it works fine for what we need which does the change tracking of /// claims, roles and logins directly on the user model. /// - public abstract class IdentityUser : IRememberBeingDirty + public abstract class UmbracoIdentityUser : IRememberBeingDirty { private int _id; private string _email; @@ -32,9 +32,9 @@ namespace Umbraco.Core.Models.Identity private ObservableCollection _roles; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public IdentityUser() + public UmbracoIdentityUser() { // must initialize before setting groups _roles = new ObservableCollection(); diff --git a/src/Umbraco.Core/Security/BackOfficeIdentityUser.cs b/src/Umbraco.Core/Security/BackOfficeIdentityUser.cs index 051a68a362..4de1ae4d0f 100644 --- a/src/Umbraco.Core/Security/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Core/Security/BackOfficeIdentityUser.cs @@ -1,10 +1,6 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.ComponentModel; using System.Linq; -using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.Models; using Umbraco.Core.Models.Entities; using Umbraco.Core.Models.Identity; @@ -12,7 +8,10 @@ using Umbraco.Core.Models.Membership; namespace Umbraco.Core.Security { - public class BackOfficeIdentityUser : IdentityUser + /// + /// The identity user used for the back office + /// + public class BackOfficeIdentityUser : UmbracoIdentityUser { private string _name; private string _passwordConfig; @@ -34,11 +33,7 @@ namespace Umbraco.Core.Security /// /// Used to construct a new instance without an identity /// - /// - /// /// This is allowed to be null (but would need to be filled in if trying to persist this instance) - /// - /// public static BackOfficeIdentityUser CreateNew(GlobalSettings globalSettings, string username, string email, string culture, string name = null) { if (string.IsNullOrWhiteSpace(username)) @@ -80,9 +75,6 @@ namespace Umbraco.Core.Security /// /// Initializes a new instance of the class. /// - /// - /// - /// public BackOfficeIdentityUser(GlobalSettings globalSettings, int userId, IEnumerable groups) : this(globalSettings, groups.ToArray()) { @@ -184,7 +176,7 @@ namespace Umbraco.Core.Security /// - /// Based on the user's lockout end date, this will determine if they are locked out + /// Gets a value indicating whether the user is locked out based on the user's lockout end date /// public bool IsLockedOut { @@ -196,7 +188,7 @@ namespace Umbraco.Core.Security } /// - /// This is a 1:1 mapping with IUser.IsApproved + /// Gets or sets a value indicating the IUser IsApproved /// public bool IsApproved { get; set; } diff --git a/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserStore.cs index 234d6eae66..e74690b76f 100644 --- a/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserStore.cs @@ -6,7 +6,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; -using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.Models; using Umbraco.Core.Mapping; using Umbraco.Core.Models; @@ -18,6 +17,8 @@ using Umbraco.Core.Services; namespace Umbraco.Core.BackOffice { + // TODO: Make this into a base class that can be re-used + public class BackOfficeUserStore : DisposableObjectSlim, IUserPasswordStore, IUserEmailStore, @@ -28,11 +29,11 @@ namespace Umbraco.Core.BackOffice IUserSessionStore // TODO: This would require additional columns/tables and then a lot of extra coding support to make this happen natively within umbraco - //IUserTwoFactorStore, + // IUserTwoFactorStore, // TODO: This would require additional columns/tables for now people will need to implement this on their own - //IUserPhoneNumberStore, + // IUserPhoneNumberStore, // TODO: To do this we need to implement IQueryable - we'll have an IQuerable implementation soon with the UmbracoLinqPadDriver implementation - //IQueryableUserStore + // IQueryableUserStore { private readonly IScopeProvider _scopeProvider; private readonly IUserService _userService; @@ -42,15 +43,16 @@ namespace Umbraco.Core.BackOffice private readonly UmbracoMapper _mapper; private bool _disposed = false; + /// + /// Initializes a new instance of the class. + /// public BackOfficeUserStore(IScopeProvider scopeProvider, IUserService userService, IEntityService entityService, IExternalLoginService externalLoginService, IOptions globalSettings, UmbracoMapper mapper) { _scopeProvider = scopeProvider; - _userService = userService; + _userService = userService ?? throw new ArgumentNullException(nameof(userService)); _entityService = entityService; - _externalLoginService = externalLoginService; + _externalLoginService = externalLoginService ?? throw new ArgumentNullException(nameof(externalLoginService)); _globalSettings = globalSettings.Value; - if (userService == null) throw new ArgumentNullException("userService"); - if (externalLoginService == null) throw new ArgumentNullException("externalLoginService"); _mapper = mapper; _userService = userService; _externalLoginService = externalLoginService; @@ -59,10 +61,7 @@ namespace Umbraco.Core.BackOffice /// /// Handles the disposal of resources. Derived from abstract class which handles common required locking logic. /// - protected override void DisposeResources() - { - _disposed = true; - } + protected override void DisposeResources() => _disposed = true; public Task GetUserIdAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) { @@ -105,9 +104,6 @@ namespace Umbraco.Core.BackOffice /// /// Insert a new user /// - /// - /// - /// public Task CreateAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -158,9 +154,6 @@ namespace Umbraco.Core.BackOffice /// /// Update a user /// - /// - /// - /// public Task UpdateAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -206,8 +199,6 @@ namespace Umbraco.Core.BackOffice /// /// Delete a user /// - /// - /// public Task DeleteAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -227,9 +218,6 @@ namespace Umbraco.Core.BackOffice /// /// Finds a user /// - /// - /// - /// public async Task FindByIdAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -244,9 +232,6 @@ namespace Umbraco.Core.BackOffice /// /// Find a user by name /// - /// - /// - /// public async Task FindByNameAsync(string userName, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -265,9 +250,6 @@ namespace Umbraco.Core.BackOffice /// /// Set the user password hash /// - /// - /// - /// public Task SetPasswordHashAsync(BackOfficeIdentityUser user, string passwordHash, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -278,6 +260,7 @@ namespace Umbraco.Core.BackOffice user.PasswordHash = passwordHash; user.PasswordConfig = null; // Clear this so that it's reset at the repository level + user.LastPasswordChangeDateUtc = DateTime.UtcNow; return Task.CompletedTask; } @@ -285,9 +268,6 @@ namespace Umbraco.Core.BackOffice /// /// Get the user password hash /// - /// - /// - /// public Task GetPasswordHashAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -300,9 +280,6 @@ namespace Umbraco.Core.BackOffice /// /// Returns true if a user has a password set /// - /// - /// - /// public Task HasPasswordAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -315,9 +292,6 @@ namespace Umbraco.Core.BackOffice /// /// Set the user email /// - /// - /// - /// public Task SetEmailAsync(BackOfficeIdentityUser user, string email, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -333,9 +307,6 @@ namespace Umbraco.Core.BackOffice /// /// Get the user email /// - /// - /// - /// public Task GetEmailAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -348,9 +319,6 @@ namespace Umbraco.Core.BackOffice /// /// Returns true if the user email is confirmed /// - /// - /// - /// public Task GetEmailConfirmedAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -363,9 +331,6 @@ namespace Umbraco.Core.BackOffice /// /// Sets whether the user email is confirmed /// - /// - /// - /// public Task SetEmailConfirmedAsync(BackOfficeIdentityUser user, bool confirmed, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -377,9 +342,6 @@ namespace Umbraco.Core.BackOffice /// /// Returns the user associated with this email /// - /// - /// - /// public Task FindByEmailAsync(string email, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -393,22 +355,14 @@ namespace Umbraco.Core.BackOffice } public Task GetNormalizedEmailAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) - { - return GetEmailAsync(user, cancellationToken); - } + => GetEmailAsync(user, cancellationToken); public Task SetNormalizedEmailAsync(BackOfficeIdentityUser user, string normalizedEmail, CancellationToken cancellationToken) - { - return SetEmailAsync(user, normalizedEmail, cancellationToken); - } + => 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(); @@ -427,11 +381,6 @@ namespace Umbraco.Core.BackOffice /// /// 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(); @@ -447,9 +396,6 @@ namespace Umbraco.Core.BackOffice /// /// Returns the linked accounts for this user /// - /// - /// - /// public Task> GetLoginsAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -498,10 +444,6 @@ namespace Umbraco.Core.BackOffice /// /// Adds a user to a role (user group) /// - /// - /// - /// - /// public Task AddToRoleAsync(BackOfficeIdentityUser user, string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -523,10 +465,6 @@ namespace Umbraco.Core.BackOffice /// /// Removes the role (user group) for the user /// - /// - /// - /// - /// public Task RemoveFromRoleAsync(BackOfficeIdentityUser user, string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -548,9 +486,6 @@ namespace Umbraco.Core.BackOffice /// /// Returns the roles (user groups) for this user /// - /// - /// - /// public Task> GetRolesAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -562,10 +497,6 @@ namespace Umbraco.Core.BackOffice /// /// Returns true if a user is in the role /// - /// - /// - /// - /// public Task IsInRoleAsync(BackOfficeIdentityUser user, string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -597,10 +528,6 @@ namespace Umbraco.Core.BackOffice /// /// Set the security stamp for the user /// - /// - /// - /// - /// public Task SetSecurityStampAsync(BackOfficeIdentityUser user, string stamp, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -614,9 +541,6 @@ namespace Umbraco.Core.BackOffice /// /// Get the user security stamp /// - /// - /// - /// public Task GetSecurityStampAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -644,10 +568,7 @@ namespace Umbraco.Core.BackOffice /// /// 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)) @@ -664,9 +585,6 @@ namespace Umbraco.Core.BackOffice /// /// 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 /// @@ -683,9 +601,6 @@ namespace Umbraco.Core.BackOffice /// /// Used to record when an attempt to access the user has failed /// - /// - /// - /// public Task IncrementAccessFailedCountAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -699,9 +614,6 @@ namespace Umbraco.Core.BackOffice /// /// 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(); @@ -716,9 +628,6 @@ namespace Umbraco.Core.BackOffice /// 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(); @@ -730,9 +639,6 @@ namespace Umbraco.Core.BackOffice /// /// Returns true /// - /// - /// - /// public Task GetLockoutEnabledAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); @@ -744,9 +650,6 @@ namespace Umbraco.Core.BackOffice /// /// 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(); @@ -904,8 +807,7 @@ namespace Umbraco.Core.BackOffice public Task ValidateSessionIdAsync(string userId, string sessionId) { - Guid guidSessionId; - if (Guid.TryParse(sessionId, out guidSessionId)) + if (Guid.TryParse(sessionId, out Guid guidSessionId)) { return Task.FromResult(_userService.ValidateLoginSession(UserIdToInt(userId), guidSessionId)); } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeUserManager.cs b/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs similarity index 56% rename from src/Umbraco.Web.BackOffice/Security/BackOfficeUserManager.cs rename to src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs index cc9b9410a6..9f77cdb7d4 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs @@ -11,6 +11,7 @@ using Umbraco.Core; using Umbraco.Core.BackOffice; using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.Models; +using Umbraco.Core.Models.Identity; using Umbraco.Core.Models.Membership; using Umbraco.Core.Security; using Umbraco.Extensions; @@ -20,9 +21,10 @@ using Umbraco.Web.Models.ContentEditing; namespace Umbraco.Web.Common.Security { - - public class BackOfficeUserManager : BackOfficeUserManager, IBackOfficeUserManager + public class BackOfficeUserManager : UmbracoUserManager, IBackOfficeUserManager { + private readonly IHttpContextAccessor _httpContextAccessor; + public BackOfficeUserManager( IIpResolver ipResolver, IUserStore store, @@ -36,135 +38,11 @@ namespace Umbraco.Web.Common.Security IHttpContextAccessor httpContextAccessor, ILogger> logger, IOptions passwordConfiguration) - : base(ipResolver, store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, httpContextAccessor, logger, passwordConfiguration) + : base(ipResolver, store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger, passwordConfiguration) { - } - } - - public class BackOfficeUserManager : UserManager - where T : BackOfficeIdentityUser - { - private PasswordGenerator _passwordGenerator; - private readonly IHttpContextAccessor _httpContextAccessor; - - public BackOfficeUserManager( - IIpResolver ipResolver, - IUserStore store, - IOptions optionsAccessor, - IPasswordHasher passwordHasher, - IEnumerable> userValidators, - IEnumerable> passwordValidators, - BackOfficeLookupNormalizer keyNormalizer, - BackOfficeIdentityErrorDescriber errors, - IServiceProvider services, - IHttpContextAccessor httpContextAccessor, - ILogger> logger, - IOptions passwordConfiguration) - : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) - { - IpResolver = ipResolver ?? throw new ArgumentNullException(nameof(ipResolver)); _httpContextAccessor = httpContextAccessor; - PasswordConfiguration = passwordConfiguration.Value ?? throw new ArgumentNullException(nameof(passwordConfiguration)); - } - // We don't support an IUserClaimStore and don't need to (at least currently) - public override bool SupportsUserClaim => false; - - // It would be nice to support this but we don't need to currently and that would require IQueryable support for our user service/repository - public override bool SupportsQueryableUsers => false; - - /// - /// Developers will need to override this to support custom 2 factor auth - /// - public override bool SupportsUserTwoFactor => false; - - // We haven't needed to support this yet, though might be necessary for 2FA - public override bool SupportsUserPhoneNumber => false; - - /// - /// Replace the underlying options property with our own strongly typed version - /// - public new BackOfficeIdentityOptions Options - { - get => (BackOfficeIdentityOptions)base.Options; - set => base.Options = value; - } - - /// - /// Used to validate a user's session - /// - /// The user id - /// The sesion id - /// True if the sesion is valid, else false - public virtual async Task ValidateSessionIdAsync(string 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 - /// - /// The - /// An - protected virtual IPasswordHasher GetDefaultPasswordHasher(IPasswordConfiguration passwordConfiguration) => new PasswordHasher(); - - /// - /// Gets/sets the default back office user password checker - /// - public IBackOfficeUserPasswordChecker BackOfficeUserPasswordChecker { get; set; } - public IPasswordConfiguration PasswordConfiguration { get; protected set; } - public IIpResolver IpResolver { get; } - - /// - /// Helper method to generate a password for a user based on the current password validator - /// - /// The generated password - 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 - /// - /// The user - /// True if the user is locked out, else false - /// - /// 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); - } - - /// - /// Logic used to validate a username and password - /// /// /// /// By default this uses the standard ASP.Net Identity approach which is: @@ -180,7 +58,7 @@ namespace Umbraco.Web.Common.Security /// 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) + public override async Task CheckPasswordAsync(BackOfficeIdentityUser user, string password) { if (BackOfficeUserPasswordChecker != null) { @@ -198,36 +76,49 @@ namespace Umbraco.Web.Common.Security } } - // 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 + /// 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 /// - /// The userId - /// The reset password token - /// The new password to set it to - /// The + /// The user + /// True if the user is locked out, else false /// - /// 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 + /// 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 async Task ChangePasswordWithResetAsync(int userId, string token, string newPassword) + public override async Task IsLockedOutAsync(BackOfficeIdentityUser user) { - T user = await FindByIdAsync(userId.ToString()); if (user == null) { - throw new InvalidOperationException("Could not find user"); + throw new ArgumentNullException(nameof(user)); } - IdentityResult result = await base.ResetPasswordAsync(user, token, newPassword); + if (user.IsApproved == false) + { + return true; + } + + return await base.IsLockedOutAsync(user); + } + + public override async Task AccessFailedAsync(BackOfficeIdentityUser user) + { + IdentityResult result = await base.AccessFailedAsync(user); + + // Slightly confusing: this will return a Success if we successfully update the AccessFailed count + if (result.Succeeded) + { + RaiseLoginFailedEvent(_httpContextAccessor.HttpContext?.User, user.Id); + } + + return result; + } + + public override async Task ChangePasswordWithResetAsync(int userId, string token, string newPassword) + { + IdentityResult result = await base.ChangePasswordWithResetAsync(userId, token, newPassword); if (result.Succeeded) { RaisePasswordChangedEvent(_httpContextAccessor.HttpContext?.User, userId); @@ -236,79 +127,19 @@ namespace Umbraco.Web.Common.Security return result; } - public override async Task ChangePasswordAsync(T user, string currentPassword, string newPassword) + public override async Task ChangePasswordAsync(BackOfficeIdentityUser user, string currentPassword, string newPassword) { IdentityResult result = await base.ChangePasswordAsync(user, currentPassword, newPassword); if (result.Succeeded) { RaisePasswordChangedEvent(_httpContextAccessor.HttpContext?.User, user.Id); } + return result; } - /// - /// Override to determine how to hash the password - /// /// - protected override async Task UpdatePasswordHash(T user, string newPassword, bool validatePassword) - { - user.LastPasswordChangeDateUtc = DateTime.UtcNow; - - if (validatePassword) - { - IdentityResult 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() => Guid.NewGuid().ToString(); - - /// - public override async Task SetLockoutEndDateAsync(T user, DateTimeOffset? lockoutEnd) + public override async Task SetLockoutEndDateAsync(BackOfficeIdentityUser user, DateTimeOffset? lockoutEnd) { if (user == null) { @@ -320,7 +151,7 @@ namespace Umbraco.Web.Common.Security // The way we unlock is by setting the lockoutEnd date to the current datetime if (result.Succeeded && lockoutEnd >= DateTimeOffset.UtcNow) { - RaiseAccountLockedEvent(_httpContextAccessor.HttpContext?.User, user.Id); + RaiseAccountLockedEvent(_httpContextAccessor.HttpContext?.User, user.Id); } else { @@ -334,62 +165,12 @@ namespace Umbraco.Web.Common.Security } /// - public override async Task ResetAccessFailedCountAsync(T user) + public override async Task ResetAccessFailedCountAsync(BackOfficeIdentityUser 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); + IdentityResult result = await base.ResetAccessFailedCountAsync(user); // raise the event now that it's reset RaiseResetAccessFailedCountEvent(_httpContextAccessor.HttpContext?.User, user.Id); - return await UpdateAsync(user); - } - - /// - /// Overrides the Microsoft ASP.NET user management method - /// - /// - 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 - } - - IdentityResult result = await UpdateAsync(user); - - // Slightly confusing: this will return a Success if we successfully update the AccessFailed count - if (result.Succeeded) - { - RaiseLoginFailedEvent(_httpContextAccessor.HttpContext?.User, user.Id); - } return result; } diff --git a/src/Umbraco.Web.Common/Security/UmbracoUserManager.cs b/src/Umbraco.Web.Common/Security/UmbracoUserManager.cs new file mode 100644 index 0000000000..a555cca4be --- /dev/null +++ b/src/Umbraco.Web.Common/Security/UmbracoUserManager.cs @@ -0,0 +1,241 @@ +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.BackOffice; +using Umbraco.Core.Configuration; +using Umbraco.Core.Configuration.Models; +using Umbraco.Core.Models.Identity; +using Umbraco.Core.Security; +using Umbraco.Net; + + +namespace Umbraco.Web.Common.Security +{ + + /// + /// Abstract class for Umbraco User Managers for back office users or front-end members + /// + /// The type of user + public abstract class UmbracoUserManager : UserManager + where T : UmbracoIdentityUser + { + private PasswordGenerator _passwordGenerator; + + /// + /// Initializes a new instance of the class. + /// + public UmbracoUserManager( + IIpResolver ipResolver, + IUserStore store, + IOptions optionsAccessor, + IPasswordHasher passwordHasher, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + BackOfficeLookupNormalizer keyNormalizer, + BackOfficeIdentityErrorDescriber errors, + IServiceProvider services, + ILogger> logger, + IOptions passwordConfiguration) + : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) + { + IpResolver = ipResolver ?? throw new ArgumentNullException(nameof(ipResolver)); + PasswordConfiguration = passwordConfiguration.Value ?? throw new ArgumentNullException(nameof(passwordConfiguration)); + } + + // We don't support an IUserClaimStore and don't need to (at least currently) + public override bool SupportsUserClaim => false; + + // It would be nice to support this but we don't need to currently and that would require IQueryable support for our user service/repository + public override bool SupportsQueryableUsers => false; + + /// + /// Developers will need to override this to support custom 2 factor auth + /// + public override bool SupportsUserTwoFactor => false; + + // We haven't needed to support this yet, though might be necessary for 2FA + public override bool SupportsUserPhoneNumber => false; + + /// + /// Used to validate a user's session + /// + /// The user id + /// The sesion id + /// True if the sesion is valid, else false + public virtual async Task ValidateSessionIdAsync(string 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 + /// + /// The + /// An + protected virtual IPasswordHasher GetDefaultPasswordHasher(IPasswordConfiguration passwordConfiguration) => new PasswordHasher(); + + /// + /// Gets or sets the default back office user password checker + /// + public IBackOfficeUserPasswordChecker BackOfficeUserPasswordChecker { get; set; } + + public IPasswordConfiguration PasswordConfiguration { get; protected set; } + + public IIpResolver IpResolver { get; } + + /// + /// Helper method to generate a password for a user based on the current password validator + /// + /// The generated password + public string GeneratePassword() + { + if (_passwordGenerator == null) + { + _passwordGenerator = new PasswordGenerator(PasswordConfiguration); + } + + var password = _passwordGenerator.GeneratePassword(); + return password; + } + + /// + public override async Task CheckPasswordAsync(T user, string password) + { + // 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 + /// + /// The userId + /// The reset password token + /// The new password to set it to + /// The + /// + /// 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 virtual async Task ChangePasswordWithResetAsync(int userId, string token, string newPassword) + { + T user = await FindByIdAsync(userId.ToString()); + if (user == null) + { + throw new InvalidOperationException("Could not find user"); + } + + IdentityResult result = await base.ResetPasswordAsync(user, token, newPassword); + return result; + } + + /// + /// 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() => Guid.NewGuid().ToString(); + + /// + public override async Task SetLockoutEndDateAsync(T user, DateTimeOffset? lockoutEnd) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + IdentityResult 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) + { + // 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); + + return await UpdateAsync(user); + } + + /// + /// Overrides the Microsoft ASP.NET user management method + /// + /// + 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 + } + + IdentityResult result = await UpdateAsync(user); + return result; + } + + } +}