More migration of back office identity. Blocked by password validator

This commit is contained in:
Scott Brady
2020-05-14 22:21:19 +01:00
parent 332946dbd8
commit 2c78f2f1d7
10 changed files with 1299 additions and 0 deletions

View File

@@ -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
{
/// <summary>
/// This will return the current back office identity if the IPrincipal is the correct type
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
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<UmbracoBackOfficeIdentity>().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;
}
}
}

View File

@@ -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<BackOfficeIdentityUser>
{
public const string OwinMarkerKey = "Umbraco.Web.Security.Identity.BackOfficeUserManagerMarker";
public BackOfficeUserManager(
IPasswordConfiguration passwordConfiguration,
IIpResolver ipResolver,
IUserStore<BackOfficeIdentityUser> store,
IOptions<IdentityOptions> optionsAccessor,
IEnumerable<IUserValidator<BackOfficeIdentityUser>> userValidators,
IEnumerable<IPasswordValidator<BackOfficeIdentityUser>> passwordValidators,
ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors,
ILogger<UserManager<BackOfficeIdentityUser>> logger)
: base(passwordConfiguration, ipResolver, store, optionsAccessor, userValidators, passwordValidators, keyNormalizer, errors, null, logger)
{
InitUserManager(this);
}
#region Static Create methods
/// <summary>
/// Creates a BackOfficeUserManager instance with all default options and the default BackOfficeUserManager
/// </summary>
public static BackOfficeUserManager Create(
IUserService userService,
IEntityService entityService,
IExternalLoginService externalLoginService,
IGlobalSettings globalSettings,
UmbracoMapper mapper,
IPasswordConfiguration passwordConfiguration,
IIpResolver ipResolver,
IdentityErrorDescriber errors,
IDataProtectionProvider dataProtectionProvider,
ILogger<UserManager<BackOfficeIdentityUser>> logger)
{
var store = new BackOfficeUserStore(userService, entityService, externalLoginService, globalSettings, mapper);
return Create(
passwordConfiguration,
ipResolver,
store,
errors,
dataProtectionProvider,
logger);
}
/// <summary>
/// Creates a BackOfficeUserManager instance with all default options and a custom BackOfficeUserManager instance
/// </summary>
public static BackOfficeUserManager Create(
IPasswordConfiguration passwordConfiguration,
IIpResolver ipResolver,
IUserStore<BackOfficeIdentityUser> customUserStore,
IdentityErrorDescriber errors,
IDataProtectionProvider dataProtectionProvider,
ILogger<UserManager<BackOfficeIdentityUser>> logger)
{
var options = new IdentityOptions();
// Configure validation logic for usernames
var userValidators = new List<UserValidator<BackOfficeIdentityUser>> { new BackOfficeUserValidator<BackOfficeIdentityUser>() };
options.User.RequireUniqueEmail = true;
// Configure validation logic for passwords
var passwordValidators = new List<IPasswordValidator<BackOfficeIdentityUser>> { new PasswordValidator<BackOfficeIdentityUser>() };
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<IdentityOptions>(options),
userValidators,
passwordValidators,
new NopLookupNormalizer(),
errors,
logger);
}
#endregion
}
public class BackOfficeUserManager<T> : UserManager<T>
where T : BackOfficeIdentityUser
{
private PasswordGenerator _passwordGenerator;
public BackOfficeUserManager(
IPasswordConfiguration passwordConfiguration,
IIpResolver ipResolver,
IUserStore<T> store,
IOptions<IdentityOptions> optionsAccessor,
IEnumerable<IUserValidator<T>> userValidators,
IEnumerable<IPasswordValidator<T>> passwordValidators,
ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors,
IServiceProvider services,
ILogger<UserManager<T>> 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;
/// <summary>
/// Developers will need to override this to support custom 2 factor auth
/// </summary>
public override bool SupportsUserTwoFactor => false;
// TODO: Support this
public override bool SupportsUserPhoneNumber => false;
#endregion
/// <summary>
/// Initializes the user manager with the correct options
/// </summary>
protected void InitUserManager(BackOfficeUserManager<T> manager)
{
// use a custom hasher based on our membership provider
PasswordHasher = GetDefaultPasswordHasher(PasswordConfiguration);
}
/// <summary>
/// Used to validate a user's session
/// </summary>
/// <param name="userId"></param>
/// <param name="sessionId"></param>
/// <returns></returns>
public virtual async Task<bool> ValidateSessionIdAsync(string userId, string sessionId)
{
var userSessionStore = Store as IUserSessionStore<T>;
//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);
}
/// <summary>
/// This will determine which password hasher to use based on what is defined in config
/// </summary>
/// <returns></returns>
protected virtual IPasswordHasher<T> GetDefaultPasswordHasher(IPasswordConfiguration passwordConfiguration)
{
//we can use the user aware password hasher (which will be the default and preferred way)
return new UserAwarePasswordHasher<T>(new PasswordSecurity(passwordConfiguration));
}
/// <summary>
/// Gets/sets the default back office user password checker
/// </summary>
public IBackOfficeUserPasswordChecker BackOfficeUserPasswordChecker { get; set; }
public IPasswordConfiguration PasswordConfiguration { get; }
public IIpResolver IpResolver { get; }
/// <summary>
/// Helper method to generate a password for a user based on the current password validator
/// </summary>
/// <returns></returns>
public string GeneratePassword()
{
if (_passwordGenerator == null) _passwordGenerator = new PasswordGenerator(PasswordConfiguration);
var password = _passwordGenerator.GeneratePassword();
return password;
}
/// <summary>
/// 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
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
/// <remarks>
/// 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
/// </remarks>
public override async Task<bool> 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
/// <summary>
/// Logic used to validate a username and password
/// </summary>
/// <param name="user"></param>
/// <param name="password"></param>
/// <returns></returns>
/// <remarks>
/// 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.
/// </remarks>
public override async Task<bool> 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);
}
/// <summary>
/// This is a special method that will reset the password but will raise the Password Changed event instead of the reset event
/// </summary>
/// <param name="userId"></param>
/// <param name="token"></param>
/// <param name="newPassword"></param>
/// <returns></returns>
/// <remarks>
/// 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
/// </remarks>
public async Task<IdentityResult> 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<IdentityResult> ChangePasswordAsync(T user, string currentPassword, string newPassword)
{
var result = await base.ChangePasswordAsync(user, currentPassword, newPassword);
if (result.Succeeded) RaisePasswordChangedEvent(user.Id);
return result;
}
/// <summary>
/// Override to determine how to hash the password
/// </summary>
/// <param name="user"></param>
/// <param name="newPassword"></param>
/// <param name="validatePassword"></param>
/// <returns></returns>
/// <remarks>
/// This method is called anytime the password needs to be hashed for storage (i.e. including when reset password is used)
/// </remarks>
protected override async Task<IdentityResult> 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<T>;
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;
}
/// <summary>
/// This is copied from the underlying .NET base class since they decided to not expose it
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
private async Task UpdateSecurityStampInternal(T user)
{
if (SupportsUserSecurityStamp == false) return;
await GetSecurityStore().SetSecurityStampAsync(user, NewSecurityStamp(), CancellationToken.None);
}
/// <summary>
/// This is copied from the underlying .NET base class since they decided to not expose it
/// </summary>
/// <returns></returns>
private IUserSecurityStampStore<T> GetSecurityStore()
{
var store = Store as IUserSecurityStampStore<T>;
if (store == null) throw new NotSupportedException("The current user store does not implement " + typeof(IUserSecurityStampStore<>));
return store;
}
/// <summary>
/// This is copied from the underlying .NET base class since they decided to not expose it
/// </summary>
/// <returns></returns>
private static string NewSecurityStamp()
{
return Guid.NewGuid().ToString();
}
#endregion
public override async Task<IdentityResult> 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<IdentityResult> ResetAccessFailedCountAsync(T user)
{
if (user == null) throw new ArgumentNullException(nameof(user));
var lockoutStore = (IUserLockoutStore<T>)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);
}
/// <summary>
/// Overrides the Microsoft ASP.NET user management method
/// </summary>
/// <param name="user"></param>
/// <returns>
/// returns a Async Task<IdentityResult />
/// </returns>
/// <remarks>
/// Doesn't set fail attempts back to 0
/// </remarks>
public override async Task<IdentityResult> AccessFailedAsync(T user)
{
if (user == null) throw new ArgumentNullException(nameof(user));
var lockoutStore = Store as IUserLockoutStore<T>;
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);
}
}
}

View File

@@ -0,0 +1,12 @@
namespace Umbraco.Web.BackOffice.Identity
{
/// <summary>
/// The result returned from the IBackOfficeUserPasswordChecker
/// </summary>
public enum BackOfficeUserPasswordCheckerResult
{
ValidCredentials,
InvalidCredentials,
FallbackToDefaultChecker
}
}

View File

@@ -0,0 +1,19 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
namespace Umbraco.Web.BackOffice.Identity
{
public class BackOfficeUserValidator<T> : UserValidator<T>
where T : BackOfficeIdentityUser
{
public override async Task<IdentityResult> ValidateAsync(UserManager<T> 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;
}
}
}

View File

@@ -0,0 +1,24 @@
using System.Threading.Tasks;
namespace Umbraco.Web.BackOffice.Identity
{
/// <summary>
/// Used by the BackOfficeUserManager to check the username/password which allows for developers to more easily
/// set the logic for this procedure.
/// </summary>
public interface IBackOfficeUserPasswordChecker
{
/// <summary>
/// Checks a password for a user
/// </summary>
/// <param name="user"></param>
/// <param name="password"></param>
/// <returns></returns>
/// <remarks>
/// 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
/// </remarks>
Task<BackOfficeUserPasswordCheckerResult> CheckPasswordAsync(BackOfficeIdentityUser user, string password);
}
}

View File

@@ -0,0 +1,132 @@
using System;
using System.Threading;
using Umbraco.Extensions;
namespace Umbraco.Web.BackOffice.Identity
{
/// <summary>
/// This class is used by events raised from the BackofficeUserManager
/// </summary>
public class IdentityAuditEventArgs : EventArgs
{
/// <summary>
/// The action that got triggered from the audit event
/// </summary>
public AuditEvent Action { get; private set; }
/// <summary>
/// Current date/time in UTC format
/// </summary>
public DateTime DateTimeUtc { get; private set; }
/// <summary>
/// The source IP address of the user performing the action
/// </summary>
public string IpAddress { get; private set; }
/// <summary>
/// The user affected by the event raised
/// </summary>
public int AffectedUser { get; private set; }
/// <summary>
/// If a user is performing an action on a different user, then this will be set. Otherwise it will be -1
/// </summary>
public int PerformingUser { get; private set; }
/// <summary>
/// An optional comment about the action being logged
/// </summary>
public string Comment { get; private set; }
/// <summary>
/// This property is always empty except in the LoginFailed event for an unknown user trying to login
/// </summary>
public string Username { get; private set; }
/// <summary>
/// Default constructor
/// </summary>
/// <param name="action"></param>
/// <param name="ipAddress"></param>
/// <param name="comment"></param>
/// <param name="performingUser"></param>
/// <param name="affectedUser"></param>
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;
}
/// <summary>
/// Creates an instance without a performing or affected user (the id will be set to -1)
/// </summary>
/// <param name="action"></param>
/// <param name="ipAddress"></param>
/// <param name="username"></param>
/// <param name="comment"></param>
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;
}
/// <summary>
/// Returns the current logged in backoffice user's Id logging if there is one
/// </summary>
/// <returns></returns>
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
}
}

View File

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Identity;
namespace Umbraco.Web.BackOffice.Identity
{
/// <summary>
/// No-op lookup normalizer to maintain compatibility with ASP.NET Identity 2
/// </summary>
public class NopLookupNormalizer : ILookupNormalizer
{
public string NormalizeName(string name) => name;
public string NormalizeEmail(string email) => email;
}
}

View File

@@ -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
{
/// <summary>
/// Handles password hashing and formatting
/// </summary>
public class PasswordSecurity
{
public IPasswordConfiguration PasswordConfiguration { get; }
public PasswordGenerator _generator;
public ConfiguredPasswordValidator _validator;
/// <summary>
/// Constructor
/// </summary>
/// <param name="passwordConfiguration"></param>
public PasswordSecurity(IPasswordConfiguration passwordConfiguration)
{
PasswordConfiguration = passwordConfiguration;
}
/// <summary>
/// Checks if the password passes validation rules
/// </summary>
/// <param name="password"></param>
/// <returns></returns>
public async Task<Attempt<IEnumerable<string>>> IsValidPasswordAsync(string password)
{
if (_validator == null)
_validator = new ConfiguredPasswordValidator(PasswordConfiguration);
var result = await _validator.ValidateAsync(password);
if (result.Succeeded)
return Attempt<IEnumerable<string>>.Succeed();
return Attempt<IEnumerable<string>>.Fail(result.Errors);
}
public string GeneratePassword()
{
if (_generator == null)
_generator = new PasswordGenerator(PasswordConfiguration);
return _generator.GeneratePassword();
}
/// <summary>
/// Returns a hashed password value used to store in a data store
/// </summary>
/// <param name="password"></param>
/// <returns></returns>
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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="hashedPassword"></param>
/// <param name="salt"></param>
/// <returns></returns>
public string FormatPasswordForStorage(string hashedPassword, string salt)
{
return salt + hashedPassword;
}
/// <summary>
/// Hashes a password with a given salt
/// </summary>
/// <param name="pass"></param>
/// <param name="salt"></param>
/// <returns></returns>
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);
}
/// <summary>
/// Verifies if the password matches the expected hash+salt of the stored password string
/// </summary>
/// <param name="password">The password.</param>
/// <param name="dbPassword">The value of the password stored in a data store.</param>
/// <returns></returns>
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;
}
/// <summary>
/// Create a new password hash and a new salt
/// </summary>
/// <param name="newPassword"></param>
/// <param name="salt"></param>
/// <returns></returns>
public string HashNewPassword(string newPassword, out string salt)
{
salt = GenerateSalt();
return HashPassword(newPassword, salt);
}
/// <summary>
/// Parses out the hashed password and the salt from the stored password string value
/// </summary>
/// <param name="storedString"></param>
/// <param name="salt">returns the salt</param>
/// <returns></returns>
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);
}
/// <summary>
/// Return the hash algorithm to use
/// </summary>
/// <param name="password"></param>
/// <returns></returns>
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;
}
/// <summary>
/// Encodes the password.
/// </summary>
/// <param name="password">The password.</param>
/// <returns>The encoded password.</returns>
private string LegacyEncodePassword(string password)
{
var hashAlgorith = GetHashAlgorithm(password);
var encodedPassword = Convert.ToBase64String(hashAlgorith.ComputeHash(Encoding.Unicode.GetBytes(password)));
return encodedPassword;
}
}
}

View File

@@ -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
{
/// <summary>
/// A custom user identity for the Umbraco backoffice
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[Serializable]
public class UmbracoBackOfficeIdentity : ClaimsIdentity
{
public static UmbracoBackOfficeIdentity FromClaimsIdentity(ClaimsIdentity identity)
{
return new UmbracoBackOfficeIdentity(identity);
}
/// <summary>
/// Creates a new UmbracoBackOfficeIdentity
/// </summary>
/// <param name="userId"></param>
/// <param name="username"></param>
/// <param name="realName"></param>
/// <param name="startContentNodes"></param>
/// <param name="startMediaNodes"></param>
/// <param name="culture"></param>
/// <param name="sessionId"></param>
/// <param name="securityStamp"></param>
/// <param name="allowedApps"></param>
/// <param name="roles"></param>
public UmbracoBackOfficeIdentity(int userId, string username, string realName,
IEnumerable<int> startContentNodes, IEnumerable<int> startMediaNodes, string culture,
string sessionId, string securityStamp, IEnumerable<string> allowedApps, IEnumerable<string> roles)
: base(Enumerable.Empty<Claim>(), 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);
}
/// <summary>
/// Creates a new UmbracoBackOfficeIdentity
/// </summary>
/// <param name="childIdentity">
/// The original identity created by the ClaimsIdentityFactory
/// </param>
/// <param name="userId"></param>
/// <param name="username"></param>
/// <param name="realName"></param>
/// <param name="startContentNodes"></param>
/// <param name="startMediaNodes"></param>
/// <param name="culture"></param>
/// <param name="sessionId"></param>
/// <param name="securityStamp"></param>
/// <param name="allowedApps"></param>
/// <param name="roles"></param>
public UmbracoBackOfficeIdentity(ClaimsIdentity childIdentity,
int userId, string username, string realName,
IEnumerable<int> startContentNodes, IEnumerable<int> startMediaNodes, string culture,
string sessionId, string securityStamp, IEnumerable<string> allowedApps, IEnumerable<string> 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);
}
/// <summary>
/// Create a back office identity based on an existing claims identity
/// </summary>
/// <param name="identity"></param>
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;
/// <summary>
/// Returns the required claim types for a back office identity
/// </summary>
/// <remarks>
/// This does not include the role claim type or allowed apps type since that is a collection and in theory could be empty
/// </remarks>
public static IEnumerable<string> RequiredBackOfficeIdentityClaimTypes => new[]
{
ClaimTypes.NameIdentifier, //id
ClaimTypes.Name, //username
ClaimTypes.GivenName,
Constants.Security.StartContentNodeIdClaimType,
Constants.Security.StartMediaNodeIdClaimType,
ClaimTypes.Locality,
Constants.Security.SessionIdClaimType,
Constants.Web.SecurityStampClaimType
};
/// <summary>
/// Adds claims based on the ctor data
/// </summary>
private void AddRequiredClaims(int userId, string username, string realName,
IEnumerable<int> startContentNodes, IEnumerable<int> startMediaNodes, string culture,
string sessionId, string securityStamp, IEnumerable<string> allowedApps, IEnumerable<string> 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));
}
}
}
/// <inheritdoc />
/// <summary>
/// Gets the type of authenticated identity.
/// </summary>
/// <returns>
/// The type of authenticated identity. This property always returns "UmbracoBackOffice".
/// </returns>
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();
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Identity;
namespace Umbraco.Web.BackOffice.Identity
{
public class UserAwarePasswordHasher<T> : IPasswordHasher<T>
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;
}
}
}