diff --git a/src/Umbraco.Web.BackOffice/Extensions/ClaimsPrincipalExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000000..833522c2c1 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Extensions/ClaimsPrincipalExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Security.Principal; +using Umbraco.Core; +using Umbraco.Web.BackOffice.Identity; + +namespace Umbraco.Extensions +{ + public static class ClaimsPrincipalExtensions + { + /// + /// This will return the current back office identity if the IPrincipal is the correct type + /// + /// + /// + public static UmbracoBackOfficeIdentity GetUmbracoIdentity(this IPrincipal user) + { + //If it's already a UmbracoBackOfficeIdentity + if (user.Identity is UmbracoBackOfficeIdentity backOfficeIdentity) return backOfficeIdentity; + + //Check if there's more than one identity assigned and see if it's a UmbracoBackOfficeIdentity and use that + if (user is ClaimsPrincipal claimsPrincipal) + { + backOfficeIdentity = claimsPrincipal.Identities.OfType().FirstOrDefault(); + if (backOfficeIdentity != null) return backOfficeIdentity; + } + + //Otherwise convert to a UmbracoBackOfficeIdentity if it's auth'd and has the back office session + if (user.Identity is ClaimsIdentity claimsIdentity && claimsIdentity.IsAuthenticated && claimsIdentity.HasClaim(x => x.Type == Constants.Security.SessionIdClaimType)) + { + try + { + return UmbracoBackOfficeIdentity.FromClaimsIdentity(claimsIdentity); + } + catch (InvalidOperationException) + { + } + } + + return null; + } + } +} diff --git a/src/Umbraco.Web.BackOffice/Identity/BackOfficeUserManager.cs b/src/Umbraco.Web.BackOffice/Identity/BackOfficeUserManager.cs new file mode 100644 index 0000000000..79da59fce5 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Identity/BackOfficeUserManager.cs @@ -0,0 +1,561 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Core; +using Umbraco.Core.Configuration; +using Umbraco.Core.Mapping; +using Umbraco.Core.Security; +using Umbraco.Core.Services; +using Umbraco.Net; + +namespace Umbraco.Web.BackOffice.Identity +{ + public class BackOfficeUserManager : BackOfficeUserManager + { + public const string OwinMarkerKey = "Umbraco.Web.Security.Identity.BackOfficeUserManagerMarker"; + + public BackOfficeUserManager( + IPasswordConfiguration passwordConfiguration, + IIpResolver ipResolver, + IUserStore store, + IOptions optionsAccessor, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + ILogger> logger) + : base(passwordConfiguration, ipResolver, store, optionsAccessor, userValidators, passwordValidators, keyNormalizer, errors, null, logger) + { + InitUserManager(this); + } + + #region Static Create methods + + /// + /// Creates a BackOfficeUserManager instance with all default options and the default BackOfficeUserManager + /// + public static BackOfficeUserManager Create( + IUserService userService, + IEntityService entityService, + IExternalLoginService externalLoginService, + IGlobalSettings globalSettings, + UmbracoMapper mapper, + IPasswordConfiguration passwordConfiguration, + IIpResolver ipResolver, + IdentityErrorDescriber errors, + IDataProtectionProvider dataProtectionProvider, + ILogger> logger) + { + var store = new BackOfficeUserStore(userService, entityService, externalLoginService, globalSettings, mapper); + + return Create( + passwordConfiguration, + ipResolver, + store, + errors, + dataProtectionProvider, + logger); + } + + /// + /// Creates a BackOfficeUserManager instance with all default options and a custom BackOfficeUserManager instance + /// + public static BackOfficeUserManager Create( + IPasswordConfiguration passwordConfiguration, + IIpResolver ipResolver, + IUserStore customUserStore, + IdentityErrorDescriber errors, + IDataProtectionProvider dataProtectionProvider, + ILogger> logger) + { + var options = new IdentityOptions(); + + // Configure validation logic for usernames + var userValidators = new List> { new BackOfficeUserValidator() }; + 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; + + // 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 + // 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 BackOfficeUserManager( + passwordConfiguration, + ipResolver, + customUserStore, + new OptionsWrapper(options), + userValidators, + passwordValidators, + new NopLookupNormalizer(), + errors, + logger); + } + + #endregion + } + + public class BackOfficeUserManager : UserManager + where T : BackOfficeIdentityUser + { + private PasswordGenerator _passwordGenerator; + + public BackOfficeUserManager( + IPasswordConfiguration passwordConfiguration, + IIpResolver ipResolver, + IUserStore store, + IOptions optionsAccessor, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + IServiceProvider services, + ILogger> 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)); + } + + #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 + + /// + /// Initializes the user manager with the correct options + /// + protected void InitUserManager(BackOfficeUserManager manager) + { + // use a custom hasher based on our membership provider + PasswordHasher = GetDefaultPasswordHasher(PasswordConfiguration); + } + + /// + /// Used to validate a user's session + /// + /// + /// + /// + 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 + /// + /// + 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(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()); + 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; + } + + 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 + /// + /// + /// + /// + /// + /// + /// 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.BackOffice/Identity/BackOfficeUserPasswordCheckerResult.cs b/src/Umbraco.Web.BackOffice/Identity/BackOfficeUserPasswordCheckerResult.cs new file mode 100644 index 0000000000..72c0707115 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Identity/BackOfficeUserPasswordCheckerResult.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Web.BackOffice.Identity +{ + /// + /// The result returned from the IBackOfficeUserPasswordChecker + /// + public enum BackOfficeUserPasswordCheckerResult + { + ValidCredentials, + InvalidCredentials, + FallbackToDefaultChecker + } +} diff --git a/src/Umbraco.Web.BackOffice/Identity/BackOfficeUserValidator.cs b/src/Umbraco.Web.BackOffice/Identity/BackOfficeUserValidator.cs new file mode 100644 index 0000000000..f80d8b90db --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Identity/BackOfficeUserValidator.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; + +namespace Umbraco.Web.BackOffice.Identity +{ + public class BackOfficeUserValidator : 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.BackOffice/Identity/IBackOfficeUserPasswordChecker.cs b/src/Umbraco.Web.BackOffice/Identity/IBackOfficeUserPasswordChecker.cs new file mode 100644 index 0000000000..b94e261506 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Identity/IBackOfficeUserPasswordChecker.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; + +namespace Umbraco.Web.BackOffice.Identity +{ + /// + /// Used by the BackOfficeUserManager to check the username/password which allows for developers to more easily + /// set the logic for this procedure. + /// + public interface IBackOfficeUserPasswordChecker + { + /// + /// Checks a password for a user + /// + /// + /// + /// + /// + /// This will allow a developer to auto-link a local account which is required if the user queried doesn't exist locally. + /// The user parameter will always contain the username, if the user doesn't exist locally, the other properties will not be filled in. + /// A developer can then create a local account by filling in the properties and using UserManager.CreateAsync + /// + Task CheckPasswordAsync(BackOfficeIdentityUser user, string password); + } +} diff --git a/src/Umbraco.Web.BackOffice/Identity/IdentityAuditEventArgs.cs b/src/Umbraco.Web.BackOffice/Identity/IdentityAuditEventArgs.cs new file mode 100644 index 0000000000..cb0b04ef56 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Identity/IdentityAuditEventArgs.cs @@ -0,0 +1,132 @@ +using System; +using System.Threading; +using Umbraco.Extensions; + + +namespace Umbraco.Web.BackOffice.Identity +{ + /// + /// This class is used by events raised from the BackofficeUserManager + /// + public class IdentityAuditEventArgs : EventArgs + { + /// + /// The action that got triggered from the audit event + /// + public AuditEvent Action { get; private set; } + + /// + /// Current date/time in UTC format + /// + public DateTime DateTimeUtc { get; private set; } + + /// + /// The source IP address of the user performing the action + /// + public string IpAddress { get; private set; } + + /// + /// The user affected by the event raised + /// + public int AffectedUser { get; private set; } + + /// + /// If a user is performing an action on a different user, then this will be set. Otherwise it will be -1 + /// + public int PerformingUser { get; private set; } + + /// + /// An optional comment about the action being logged + /// + public string Comment { get; private set; } + + /// + /// This property is always empty except in the LoginFailed event for an unknown user trying to login + /// + public string Username { get; private set; } + + + /// + /// Default constructor + /// + /// + /// + /// + /// + /// + public IdentityAuditEventArgs(AuditEvent action, string ipAddress, string comment = null, int performingUser = -1, int affectedUser = -1) + { + DateTimeUtc = DateTime.UtcNow; + Action = action; + + IpAddress = ipAddress; + Comment = comment; + AffectedUser = affectedUser; + + PerformingUser = performingUser == -1 + ? GetCurrentRequestBackofficeUserId() + : performingUser; + } + + /// + /// Creates an instance without a performing or affected user (the id will be set to -1) + /// + /// + /// + /// + /// + public IdentityAuditEventArgs(AuditEvent action, string ipAddress, string username, string comment) + { + DateTimeUtc = DateTime.UtcNow; + Action = action; + + IpAddress = ipAddress; + Username = username; + Comment = comment; + + PerformingUser = -1; + } + + public IdentityAuditEventArgs(AuditEvent action, string ipAddress, string username, string comment, int performingUser) + { + DateTimeUtc = DateTime.UtcNow; + Action = action; + + IpAddress = ipAddress; + Username = username; + Comment = comment; + + PerformingUser = performingUser == -1 + ? GetCurrentRequestBackofficeUserId() + : performingUser; + } + + /// + /// Returns the current logged in backoffice user's Id logging if there is one + /// + /// + protected int GetCurrentRequestBackofficeUserId() + { + var userId = -1; + var backOfficeIdentity = Thread.CurrentPrincipal.GetUmbracoIdentity(); + if (backOfficeIdentity != null) + int.TryParse(backOfficeIdentity.Id.ToString(), out userId); + return userId; + } + } + + public enum AuditEvent + { + AccountLocked, + AccountUnlocked, + ForgotPasswordRequested, + ForgotPasswordChangedSuccess, + LoginFailed, + LoginRequiresVerification, + LoginSucces, + LogoutSuccess, + PasswordChanged, + PasswordReset, + ResetAccessFailedCount + } +} diff --git a/src/Umbraco.Web.BackOffice/Identity/NopLookupNormalizer.cs b/src/Umbraco.Web.BackOffice/Identity/NopLookupNormalizer.cs new file mode 100644 index 0000000000..c846498098 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Identity/NopLookupNormalizer.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Identity; + +namespace Umbraco.Web.BackOffice.Identity +{ + /// + /// 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.BackOffice/Identity/PasswordSecurity.cs b/src/Umbraco.Web.BackOffice/Identity/PasswordSecurity.cs new file mode 100644 index 0000000000..49405d0c5a --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Identity/PasswordSecurity.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Umbraco.Core; +using Umbraco.Core.Configuration; +using Umbraco.Core.Security; + +namespace Umbraco.Web.BackOffice.Identity +{ + /// + /// Handles password hashing and formatting + /// + public class PasswordSecurity + { + public IPasswordConfiguration PasswordConfiguration { get; } + public PasswordGenerator _generator; + public ConfiguredPasswordValidator _validator; + + /// + /// Constructor + /// + /// + public PasswordSecurity(IPasswordConfiguration passwordConfiguration) + { + PasswordConfiguration = passwordConfiguration; + } + + /// + /// Checks if the password passes validation rules + /// + /// + /// + public async Task>> IsValidPasswordAsync(string password) + { + if (_validator == null) + _validator = new ConfiguredPasswordValidator(PasswordConfiguration); + var result = await _validator.ValidateAsync(password); + if (result.Succeeded) + return Attempt>.Succeed(); + + return Attempt>.Fail(result.Errors); + } + + public string GeneratePassword() + { + if (_generator == null) + _generator = new PasswordGenerator(PasswordConfiguration); + return _generator.GeneratePassword(); + } + + /// + /// Returns a hashed password value used to store in a data store + /// + /// + /// + public string HashPasswordForStorage(string password) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("password cannot be empty", nameof(password)); + + string salt; + var hashed = HashNewPassword(password, out salt); + return FormatPasswordForStorage(hashed, salt); + } + + /// + /// If the password format is a hashed keyed algorithm then we will pre-pend the salt used to hash the password + /// to the hashed password itself. + /// + /// + /// + /// + public string FormatPasswordForStorage(string hashedPassword, string salt) + { + return salt + hashedPassword; + } + + /// + /// Hashes a password with a given salt + /// + /// + /// + /// + public string HashPassword(string pass, string salt) + { + //This is the correct way to implement this (as per the sql membership provider) + + var bytes = Encoding.Unicode.GetBytes(pass); + var saltBytes = Convert.FromBase64String(salt); + byte[] inArray; + + var hashAlgorithm = GetHashAlgorithm(pass); + var algorithm = hashAlgorithm as KeyedHashAlgorithm; + if (algorithm != null) + { + var keyedHashAlgorithm = algorithm; + if (keyedHashAlgorithm.Key.Length == saltBytes.Length) + { + //if the salt bytes is the required key length for the algorithm, use it as-is + keyedHashAlgorithm.Key = saltBytes; + } + else if (keyedHashAlgorithm.Key.Length < saltBytes.Length) + { + //if the salt bytes is too long for the required key length for the algorithm, reduce it + var numArray2 = new byte[keyedHashAlgorithm.Key.Length]; + Buffer.BlockCopy(saltBytes, 0, numArray2, 0, numArray2.Length); + keyedHashAlgorithm.Key = numArray2; + } + else + { + //if the salt bytes is too short for the required key length for the algorithm, extend it + var numArray2 = new byte[keyedHashAlgorithm.Key.Length]; + var dstOffset = 0; + while (dstOffset < numArray2.Length) + { + var count = Math.Min(saltBytes.Length, numArray2.Length - dstOffset); + Buffer.BlockCopy(saltBytes, 0, numArray2, dstOffset, count); + dstOffset += count; + } + keyedHashAlgorithm.Key = numArray2; + } + inArray = keyedHashAlgorithm.ComputeHash(bytes); + } + else + { + var buffer = new byte[saltBytes.Length + bytes.Length]; + Buffer.BlockCopy(saltBytes, 0, buffer, 0, saltBytes.Length); + Buffer.BlockCopy(bytes, 0, buffer, saltBytes.Length, bytes.Length); + inArray = hashAlgorithm.ComputeHash(buffer); + } + + return Convert.ToBase64String(inArray); + } + + /// + /// Verifies if the password matches the expected hash+salt of the stored password string + /// + /// The password. + /// The value of the password stored in a data store. + /// + public bool VerifyPassword(string password, string dbPassword) + { + if (string.IsNullOrWhiteSpace(dbPassword)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(dbPassword)); + + if (dbPassword.StartsWith(Constants.Security.EmptyPasswordPrefix)) + return false; + + var storedHashedPass = ParseStoredHashPassword(dbPassword, out var salt); + var hashed = HashPassword(password, salt); + return storedHashedPass == hashed; + } + + /// + /// Create a new password hash and a new salt + /// + /// + /// + /// + public string HashNewPassword(string newPassword, out string salt) + { + salt = GenerateSalt(); + return HashPassword(newPassword, salt); + } + + /// + /// Parses out the hashed password and the salt from the stored password string value + /// + /// + /// returns the salt + /// + public string ParseStoredHashPassword(string storedString, out string salt) + { + if (string.IsNullOrWhiteSpace(storedString)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(storedString)); + + var saltLen = GenerateSalt(); + salt = storedString.Substring(0, saltLen.Length); + return storedString.Substring(saltLen.Length); + } + + public static string GenerateSalt() + { + var numArray = new byte[16]; + new RNGCryptoServiceProvider().GetBytes(numArray); + return Convert.ToBase64String(numArray); + } + + /// + /// Return the hash algorithm to use + /// + /// + /// + public HashAlgorithm GetHashAlgorithm(string password) + { + if (PasswordConfiguration.HashAlgorithmType.IsNullOrWhiteSpace()) + throw new InvalidOperationException("No hash algorithm type specified"); + + var alg = HashAlgorithm.Create(PasswordConfiguration.HashAlgorithmType); + if (alg == null) + throw new InvalidOperationException($"The hash algorithm specified {PasswordConfiguration.HashAlgorithmType} cannot be resolved"); + + return alg; + } + + /// + /// Encodes the password. + /// + /// The password. + /// The encoded password. + private string LegacyEncodePassword(string password) + { + var hashAlgorith = GetHashAlgorithm(password); + var encodedPassword = Convert.ToBase64String(hashAlgorith.ComputeHash(Encoding.Unicode.GetBytes(password))); + return encodedPassword; + } + + + + + + } +} diff --git a/src/Umbraco.Web.BackOffice/Identity/UmbracoBackOfficeIdentity.cs b/src/Umbraco.Web.BackOffice/Identity/UmbracoBackOfficeIdentity.cs new file mode 100644 index 0000000000..ca3e6d742f --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Identity/UmbracoBackOfficeIdentity.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using Umbraco.Core; + +namespace Umbraco.Web.BackOffice.Identity +{ + + /// + /// A custom user identity for the Umbraco backoffice + /// + /// + /// This inherits from FormsIdentity for backwards compatibility reasons since we still support the forms auth cookie, in v8 we can + /// change over to 'pure' asp.net identity and just inherit from ClaimsIdentity. + /// + [Serializable] + public class UmbracoBackOfficeIdentity : ClaimsIdentity + { + public static UmbracoBackOfficeIdentity FromClaimsIdentity(ClaimsIdentity identity) + { + return new UmbracoBackOfficeIdentity(identity); + } + + /// + /// Creates a new UmbracoBackOfficeIdentity + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public UmbracoBackOfficeIdentity(int userId, string username, string realName, + IEnumerable startContentNodes, IEnumerable startMediaNodes, string culture, + string sessionId, string securityStamp, IEnumerable allowedApps, IEnumerable roles) + : base(Enumerable.Empty(), Constants.Security.BackOfficeAuthenticationType) //this ctor is used to ensure the IsAuthenticated property is true + { + if (allowedApps == null) throw new ArgumentNullException(nameof(allowedApps)); + if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(username)); + if (string.IsNullOrWhiteSpace(realName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(realName)); + if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(culture)); + if (string.IsNullOrWhiteSpace(sessionId)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(sessionId)); + if (string.IsNullOrWhiteSpace(securityStamp)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(securityStamp)); + AddRequiredClaims(userId, username, realName, startContentNodes, startMediaNodes, culture, sessionId, securityStamp, allowedApps, roles); + } + + /// + /// Creates a new UmbracoBackOfficeIdentity + /// + /// + /// The original identity created by the ClaimsIdentityFactory + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public UmbracoBackOfficeIdentity(ClaimsIdentity childIdentity, + int userId, string username, string realName, + IEnumerable startContentNodes, IEnumerable startMediaNodes, string culture, + string sessionId, string securityStamp, IEnumerable allowedApps, IEnumerable roles) + : base(childIdentity.Claims, Constants.Security.BackOfficeAuthenticationType) + { + if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(username)); + if (string.IsNullOrWhiteSpace(realName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(realName)); + if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(culture)); + if (string.IsNullOrWhiteSpace(sessionId)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(sessionId)); + if (string.IsNullOrWhiteSpace(securityStamp)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(securityStamp)); + Actor = childIdentity; + AddRequiredClaims(userId, username, realName, startContentNodes, startMediaNodes, culture, sessionId, securityStamp, allowedApps, roles); + } + + /// + /// Create a back office identity based on an existing claims identity + /// + /// + private UmbracoBackOfficeIdentity(ClaimsIdentity identity) + : base(identity.Claims, Constants.Security.BackOfficeAuthenticationType) + { + Actor = identity; + + //validate that all claims exist + foreach (var t in RequiredBackOfficeIdentityClaimTypes) + { + //if the identity doesn't have the claim, or the claim value is null + if (identity.HasClaim(x => x.Type == t) == false || identity.HasClaim(x => x.Type == t && x.Value.IsNullOrWhiteSpace())) + { + throw new InvalidOperationException("Cannot create a " + typeof(UmbracoBackOfficeIdentity) + " from " + typeof(ClaimsIdentity) + " since the required claim " + t + " is missing"); + } + } + } + + public const string Issuer = Constants.Security.BackOfficeAuthenticationType; + + /// + /// Returns the required claim types for a back office identity + /// + /// + /// This does not include the role claim type or allowed apps type since that is a collection and in theory could be empty + /// + public static IEnumerable RequiredBackOfficeIdentityClaimTypes => new[] + { + ClaimTypes.NameIdentifier, //id + ClaimTypes.Name, //username + ClaimTypes.GivenName, + Constants.Security.StartContentNodeIdClaimType, + Constants.Security.StartMediaNodeIdClaimType, + ClaimTypes.Locality, + Constants.Security.SessionIdClaimType, + Constants.Web.SecurityStampClaimType + }; + + /// + /// Adds claims based on the ctor data + /// + private void AddRequiredClaims(int userId, string username, string realName, + IEnumerable startContentNodes, IEnumerable startMediaNodes, string culture, + string sessionId, string securityStamp, IEnumerable allowedApps, IEnumerable roles) + { + //This is the id that 'identity' uses to check for the user id + if (HasClaim(x => x.Type == ClaimTypes.NameIdentifier) == false) + AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.ToInvariantString(), ClaimValueTypes.Integer32, Issuer, Issuer, this)); + + if (HasClaim(x => x.Type == ClaimTypes.Name) == false) + AddClaim(new Claim(ClaimTypes.Name, username, ClaimValueTypes.String, Issuer, Issuer, this)); + + if (HasClaim(x => x.Type == ClaimTypes.GivenName) == false) + AddClaim(new Claim(ClaimTypes.GivenName, realName, ClaimValueTypes.String, Issuer, Issuer, this)); + + if (HasClaim(x => x.Type == Constants.Security.StartContentNodeIdClaimType) == false && startContentNodes != null) + { + foreach (var startContentNode in startContentNodes) + { + AddClaim(new Claim(Constants.Security.StartContentNodeIdClaimType, startContentNode.ToInvariantString(), ClaimValueTypes.Integer32, Issuer, Issuer, this)); + } + } + + if (HasClaim(x => x.Type == Constants.Security.StartMediaNodeIdClaimType) == false && startMediaNodes != null) + { + foreach (var startMediaNode in startMediaNodes) + { + AddClaim(new Claim(Constants.Security.StartMediaNodeIdClaimType, startMediaNode.ToInvariantString(), ClaimValueTypes.Integer32, Issuer, Issuer, this)); + } + } + + if (HasClaim(x => x.Type == ClaimTypes.Locality) == false) + AddClaim(new Claim(ClaimTypes.Locality, culture, ClaimValueTypes.String, Issuer, Issuer, this)); + + if (HasClaim(x => x.Type == Constants.Security.SessionIdClaimType) == false && SessionId.IsNullOrWhiteSpace() == false) + AddClaim(new Claim(Constants.Security.SessionIdClaimType, sessionId, ClaimValueTypes.String, Issuer, Issuer, this)); + + //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 == 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) + { + foreach (var application in allowedApps) + { + AddClaim(new Claim(Constants.Security.AllowedApplicationsClaimType, application, ClaimValueTypes.String, Issuer, Issuer, this)); + } + } + + //Claims are added by the ClaimsIdentityFactory because our UserStore supports roles, however this identity might + // not be made with that factory if it was created with a different ticket so perform the check + if (HasClaim(x => x.Type == DefaultRoleClaimType) == false && roles != null) + { + //manually add them + foreach (var roleName in roles) + { + AddClaim(new Claim(RoleClaimType, roleName, ClaimValueTypes.String, Issuer, Issuer, this)); + } + } + + } + + /// + /// + /// Gets the type of authenticated identity. + /// + /// + /// The type of authenticated identity. This property always returns "UmbracoBackOffice". + /// + public override string AuthenticationType => Issuer; + + private int[] _startContentNodes; + public int[] StartContentNodes => _startContentNodes ?? (_startContentNodes = FindAll(x => x.Type == Constants.Security.StartContentNodeIdClaimType).Select(app => int.TryParse(app.Value, out var i) ? i : default).Where(x => x != default).ToArray()); + + private int[] _startMediaNodes; + public int[] StartMediaNodes => _startMediaNodes ?? (_startMediaNodes = FindAll(x => x.Type == Constants.Security.StartMediaNodeIdClaimType).Select(app => int.TryParse(app.Value, out var i) ? i : default).Where(x => x != default).ToArray()); + + 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 string RealName => this.FindFirstValue(ClaimTypes.GivenName); + + public string Username => this.FindFirstValue(ClaimTypes.Name); + + public string Culture => this.FindFirstValue(ClaimTypes.Locality); + + public string SessionId + { + get => this.FindFirstValue(Constants.Security.SessionIdClaimType); + set + { + var existing = FindFirst(Constants.Security.SessionIdClaimType); + if (existing != null) + TryRemoveClaim(existing); + AddClaim(new Claim(Constants.Security.SessionIdClaimType, value, ClaimValueTypes.String, Issuer, Issuer, this)); + } + } + + 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.BackOffice/Identity/UserAwarePasswordHasher.cs b/src/Umbraco.Web.BackOffice/Identity/UserAwarePasswordHasher.cs new file mode 100644 index 0000000000..fb2b1e71a0 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Identity/UserAwarePasswordHasher.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Identity; + +namespace Umbraco.Web.BackOffice.Identity +{ + public class UserAwarePasswordHasher : IPasswordHasher + where T : BackOfficeIdentityUser + { + private readonly PasswordSecurity _passwordSecurity; + + public UserAwarePasswordHasher(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; + } + } +}