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