using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Net; namespace Umbraco.Cms.Core.Security; /// /// Abstract class for Umbraco User Managers for back office users or front-end members /// /// The type of user /// /// /// The type password config public abstract class UmbracoUserManager : UserManager where TUser : UmbracoIdentityUser where TPasswordConfig : class, IPasswordConfiguration, new() { private PasswordGenerator? _passwordGenerator; /// /// Initializes a new instance of the class. /// public UmbracoUserManager( IIpResolver ipResolver, IUserStore store, IOptions optionsAccessor, IPasswordHasher passwordHasher, IEnumerable> userValidators, IEnumerable> passwordValidators, IdentityErrorDescriber errors, IServiceProvider services, ILogger> logger, IOptions passwordConfiguration) : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, new NoopLookupNormalizer(), errors, services, logger) { IpResolver = ipResolver ?? throw new ArgumentNullException(nameof(ipResolver)); PasswordConfiguration = passwordConfiguration.Value ?? throw new ArgumentNullException(nameof(passwordConfiguration)); } /// public override bool SupportsUserClaim => false; // We don't support an IUserClaimStore and don't need to (at least currently) /// public override bool SupportsQueryableUsers => 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 /// /// Both users and members supports 2FA /// /// public override bool SupportsUserTwoFactor => true; /// public override bool SupportsUserPhoneNumber => false; // We haven't needed to support this yet, though might be necessary for 2FA /// /// Gets the password configuration /// public IPasswordConfiguration PasswordConfiguration { get; } /// /// Gets the IP resolver /// public IIpResolver IpResolver { get; } /// /// Used to validate a user's session /// /// The user id /// The session id /// True if the session is valid, else false public virtual async Task ValidateSessionIdAsync(string? userId, string? sessionId) { // if this is not set, for backwards compat (which would be super rare), we'll just approve it // TODO: This should be removed after members supports this if (Store is not IUserSessionStore userSessionStore) { return true; } return await userSessionStore.ValidateSessionIdAsync(userId, sessionId); } /// /// Helper method to generate a password for a user based on the current password validator /// /// The generated password public string GeneratePassword() { _passwordGenerator ??= new PasswordGenerator(PasswordConfiguration); var password = _passwordGenerator.GeneratePassword(); return password; } /// /// Used to validate the password without an identity user /// Validation code is based on the default ValidatePasswordAsync code /// Should return if validation is successful /// /// The password. /// A representing whether validation was successful. public async Task ValidatePasswordAsync(string? password) { var errors = new List(); var isValid = true; foreach (IPasswordValidator v in PasswordValidators) { IdentityResult result = await v.ValidateAsync(this, null!, password); if (!result.Succeeded) { if (result.Errors.Any()) { errors.AddRange(result.Errors); } isValid = false; } } if (!isValid) { Logger.LogWarning(14, "Password validation failed: {errors}.", string.Join(";", errors.Select(e => e.Code))); return IdentityResult.Failed(errors.ToArray()); } return IdentityResult.Success; } /// public override async Task CheckPasswordAsync(TUser user, string? password) { // we cannot proceed if the user passed in does not have an identity, or if no password is provided. if (user.HasIdentity == false || password is null) { 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(string userId, string token, string newPassword) { TUser? user = await FindByIdAsync(userId) ?? throw new InvalidOperationException("Could not find user"); IdentityResult result = await ResetPasswordAsync(user, token, newPassword); return result; } /// public override async Task SetLockoutEndDateAsync(TUser 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(TUser 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); // Ensure the password config is null, so it is set to the default in repository user.PasswordConfig = null; return await UpdateAsync(user); } /// /// Overrides the Microsoft ASP.NET user management method /// /// public override async Task AccessFailedAsync(TUser user) { if (user == null) { throw new ArgumentNullException(nameof(user)); } if (Store is not IUserLockoutStore lockoutStore) { 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 } if (string.IsNullOrEmpty(user.PasswordConfig)) { // We cant pass null as that would be interpreted as the default algoritm, but due to the failing attempt we dont know. user.PasswordConfig = Constants.Security.UnknownPasswordConfigJson; } IdentityResult result = await UpdateAsync(user); return result; } /// /// 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(TUser user) { if (user == null) { throw new ArgumentNullException(nameof(user)); } if (user.IsApproved == false) { return true; } return await base.IsLockedOutAsync(user); } public async Task ValidateCredentialsAsync(string username, string password) { TUser? user = await FindByNameAsync(username); if (user is null || user.IsApproved is false) { return false; } if (Store is not IUserPasswordStore userPasswordStore) { throw new NotSupportedException("The current user store does not implement " + typeof(IUserPasswordStore<>)); } PasswordVerificationResult result = await VerifyPasswordAsync(userPasswordStore, user, password); return result == PasswordVerificationResult.Success || result == PasswordVerificationResult.SuccessRehashNeeded; } public virtual async Task> GetValidTwoFactorProvidersAsync(TUser user) { IList? results = await base.GetValidTwoFactorProvidersAsync(user); return results; } }