using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Security
{
///
/// A custom user store that uses Umbraco member data
///
public class MemberUserStore : UmbracoUserStore, IMemberUserStore
{
private const string GenericIdentityErrorCode = "IdentityErrorUserStore";
private readonly IMemberService _memberService;
private readonly IUmbracoMapper _mapper;
private readonly IScopeProvider _scopeProvider;
private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor;
private readonly IExternalLoginWithKeyService _externalLoginService;
private readonly ITwoFactorLoginService _twoFactorLoginService;
///
/// Initializes a new instance of the class for the members identity store
///
/// The member service
/// The mapper for properties
/// The scope provider
/// The error describer
/// The published snapshot accessor
/// The external login service
/// The two factor login service
[ActivatorUtilitiesConstructor]
public MemberUserStore(
IMemberService memberService,
IUmbracoMapper mapper,
IScopeProvider scopeProvider,
IdentityErrorDescriber describer,
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IExternalLoginWithKeyService externalLoginService,
ITwoFactorLoginService twoFactorLoginService
)
: base(describer)
{
_memberService = memberService ?? throw new ArgumentNullException(nameof(memberService));
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
_scopeProvider = scopeProvider ?? throw new ArgumentNullException(nameof(scopeProvider));
_publishedSnapshotAccessor = publishedSnapshotAccessor;
_externalLoginService = externalLoginService;
_twoFactorLoginService = twoFactorLoginService;
}
[Obsolete("Use ctor with IExternalLoginWithKeyService and ITwoFactorLoginService param")]
public MemberUserStore(
IMemberService memberService,
IUmbracoMapper mapper,
IScopeProvider scopeProvider,
IdentityErrorDescriber describer,
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IExternalLoginService externalLoginService)
: this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService())
{
}
[Obsolete("Use ctor with IExternalLoginWithKeyService and ITwoFactorLoginService param")]
public MemberUserStore(
IMemberService memberService,
IUmbracoMapper mapper,
IScopeProvider scopeProvider,
IdentityErrorDescriber describer,
IPublishedSnapshotAccessor publishedSnapshotAccessor)
: this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService())
{
}
///
public override Task CreateAsync(MemberIdentityUser user, CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
using IScope scope = _scopeProvider.CreateScope(autoComplete: true);
// create member
IMember memberEntity = _memberService.CreateMember(
user.UserName,
user.Email,
user.Name.IsNullOrWhiteSpace() ? user.UserName : user.Name,
user.MemberTypeAlias.IsNullOrWhiteSpace() ? Constants.Security.DefaultMemberTypeAlias : user.MemberTypeAlias);
UpdateMemberProperties(memberEntity, user);
// create the member
_memberService.Save(memberEntity);
//We need to add roles now that the member has an Id. It do not work implicit in UpdateMemberProperties
_memberService.AssignRoles(new[] { memberEntity.Id }, user.Roles.Select(x => x.RoleId).ToArray());
if (!memberEntity.HasIdentity)
{
throw new DataException("Could not create the member, check logs for details");
}
// re-assign id
user.Id = UserIdToString(memberEntity.Id);
user.Key = memberEntity.Key;
// we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it.
var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.Logins));
var isTokensPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.LoginTokens));
if (isLoginsPropertyDirty)
{
_externalLoginService.Save(
memberEntity.Key,
user.Logins.Select(x => new ExternalLogin(
x.LoginProvider,
x.ProviderKey,
x.UserData)));
}
if (isTokensPropertyDirty)
{
_externalLoginService.Save(
memberEntity.Key,
user.LoginTokens.Select(x => new ExternalLoginToken(
x.LoginProvider,
x.Name,
x.Value)));
}
return Task.FromResult(IdentityResult.Success);
}
catch (Exception ex)
{
return Task.FromResult(IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message }));
}
}
///
public override Task UpdateAsync(MemberIdentityUser user, CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
if (!int.TryParse(user.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var asInt))
{
//TODO: should this be thrown, or an identity result?
throw new InvalidOperationException("The user id must be an integer to work with the Umbraco");
}
using IScope scope = _scopeProvider.CreateScope(autoComplete: true);
IMember found = _memberService.GetById(asInt);
if (found != null)
{
// we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it.
var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.Logins));
MemberDataChangeType memberChangeType = UpdateMemberProperties(found, user);
if (memberChangeType == MemberDataChangeType.FullSave)
{
_memberService.Save(found);
}
else if (memberChangeType == MemberDataChangeType.LoginOnly)
{
// If the member is only logging in, just issue that command without
// any write locks so we are creating a bottleneck.
_memberService.SetLastLogin(found.Username, DateTime.Now);
}
if (isLoginsPropertyDirty)
{
_externalLoginService.Save(
found.Key,
user.Logins.Select(x => new ExternalLogin(
x.LoginProvider,
x.ProviderKey,
x.UserData)));
}
}
return Task.FromResult(IdentityResult.Success);
}
catch (Exception ex)
{
return Task.FromResult(IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message }));
}
}
///
public override Task DeleteAsync(MemberIdentityUser user, CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
IMember found = _memberService.GetById(UserIdToInt(user.Id));
if (found != null)
{
_memberService.Delete(found);
}
_externalLoginService.DeleteUserLogins(user.Key);
return Task.FromResult(IdentityResult.Success);
}
catch (Exception ex)
{
return Task.FromResult(IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message }));
}
}
///
protected override Task FindUserAsync(string userId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(userId))
{
throw new ArgumentNullException(nameof(userId));
}
IMember user = Guid.TryParse(userId, out var key) ? _memberService.GetByKey(key) : _memberService.GetById(UserIdToInt(userId));
if (user == null)
{
return Task.FromResult((MemberIdentityUser)null);
}
return Task.FromResult(AssignLoginsCallback(_mapper.Map(user)));
}
///
public override Task FindByNameAsync(string userName, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
IMember user = _memberService.GetByUsername(userName);
if (user == null)
{
return Task.FromResult((MemberIdentityUser)null);
}
MemberIdentityUser result = AssignLoginsCallback(_mapper.Map(user));
return Task.FromResult(result);
}
///
public override Task FindByEmailAsync(string email, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
IMember member = _memberService.GetByEmail(email);
MemberIdentityUser result = member == null
? null
: _mapper.Map(member);
return Task.FromResult(AssignLoginsCallback(result));
}
///
public override Task AddLoginAsync(MemberIdentityUser user, UserLoginInfo login, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
if (login == null)
{
throw new ArgumentNullException(nameof(login));
}
if (string.IsNullOrWhiteSpace(login.LoginProvider))
{
throw new ArgumentNullException(nameof(login.LoginProvider));
}
if (string.IsNullOrWhiteSpace(login.ProviderKey))
{
throw new ArgumentNullException(nameof(login.ProviderKey));
}
ICollection logins = user.Logins;
var instance = new IdentityUserLogin(
login.LoginProvider,
login.ProviderKey,
user.Id.ToString());
IdentityUserLogin userLogin = instance;
logins.Add(userLogin);
return Task.CompletedTask;
}
///
public override Task RemoveLoginAsync(MemberIdentityUser user, string loginProvider, string providerKey, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
if (string.IsNullOrWhiteSpace(loginProvider))
{
throw new ArgumentNullException(nameof(loginProvider));
}
if (string.IsNullOrWhiteSpace(providerKey))
{
throw new ArgumentNullException(nameof(providerKey));
}
IIdentityUserLogin userLogin = user.Logins.SingleOrDefault(l => l.LoginProvider == loginProvider && l.ProviderKey == providerKey);
if (userLogin != null)
{
user.Logins.Remove(userLogin);
}
return Task.CompletedTask;
}
///
public override Task> GetLoginsAsync(MemberIdentityUser user, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
return Task.FromResult((IList)user.Logins.Select(l => new UserLoginInfo(l.LoginProvider, l.ProviderKey, l.LoginProvider)).ToList());
}
///
protected override async Task> FindUserLoginAsync(string userId, string loginProvider, string providerKey, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(loginProvider))
{
throw new ArgumentNullException(nameof(loginProvider));
}
if (string.IsNullOrWhiteSpace(providerKey))
{
throw new ArgumentNullException(nameof(providerKey));
}
MemberIdentityUser user = await FindUserAsync(userId, cancellationToken);
if (user == null)
{
return await Task.FromResult((IdentityUserLogin)null);
}
IList logins = await GetLoginsAsync(user, cancellationToken);
UserLoginInfo found = logins.FirstOrDefault(x => x.ProviderKey == providerKey && x.LoginProvider == loginProvider);
if (found == null)
{
return await Task.FromResult((IdentityUserLogin)null);
}
return new IdentityUserLogin
{
LoginProvider = found.LoginProvider,
ProviderKey = found.ProviderKey,
// TODO: We don't store this value so it will be null
ProviderDisplayName = found.ProviderDisplayName,
UserId = user.Id
};
}
///
protected override Task> FindUserLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(loginProvider))
{
throw new ArgumentNullException(nameof(loginProvider));
}
if (string.IsNullOrWhiteSpace(providerKey))
{
throw new ArgumentNullException(nameof(providerKey));
}
var logins = _externalLoginService.Find(loginProvider, providerKey).ToList();
if (logins.Count == 0)
{
return Task.FromResult((IdentityUserLogin)null);
}
IIdentityUserLogin found = logins[0];
return Task.FromResult(new IdentityUserLogin
{
LoginProvider = found.LoginProvider,
ProviderKey = found.ProviderKey,
// TODO: We don't store this value so it will be null
ProviderDisplayName = null,
UserId = found.UserId
});
}
///
/// Gets a list of role names the specified user belongs to.
///
///
/// This lazy loads the roles for the member
///
public override Task> GetRolesAsync(MemberIdentityUser user, CancellationToken cancellationToken = default)
{
EnsureRoles(user);
return base.GetRolesAsync(user, cancellationToken);
}
private void EnsureRoles(MemberIdentityUser user)
{
if (user.Roles.Count == 0)
{
// if there are no roles, they either haven't been loaded since we don't eagerly
// load for members, or they just have no roles.
IEnumerable currentRoles = _memberService.GetAllRoles(user.UserName);
ICollection> roles = currentRoles.Select(role => new IdentityUserRole
{
RoleId = role,
UserId = user.Id
}).ToList();
user.Roles = roles;
}
}
///
/// Returns true if a user is in the role
///
public override Task IsInRoleAsync(MemberIdentityUser user, string roleName, CancellationToken cancellationToken = default)
{
EnsureRoles(user);
return base.IsInRoleAsync(user, roleName, cancellationToken);
}
///
/// Lists all users of a given role.
///
public override Task> GetUsersInRoleAsync(string roleName, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(roleName))
{
throw new ArgumentNullException(nameof(roleName));
}
IEnumerable members = _memberService.GetMembersByMemberType(roleName);
IList membersIdentityUsers = members.Select(x => _mapper.Map(x)).ToList();
return Task.FromResult(membersIdentityUsers);
}
///
protected override Task FindRoleAsync(string roleName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(roleName))
{
throw new ArgumentNullException(nameof(roleName));
}
IMemberGroup group = _memberService.GetAllRoles().SingleOrDefault(x => x.Name == roleName);
if (group == null)
{
return Task.FromResult((UmbracoIdentityRole)null);
}
return Task.FromResult(new UmbracoIdentityRole(group.Name)
{
//TODO: what should the alias be?
Id = group.Id.ToString()
});
}
///
protected override async Task> FindUserRoleAsync(string userId, string roleId, CancellationToken cancellationToken)
{
MemberIdentityUser user = await FindUserAsync(userId, cancellationToken);
if (user == null)
{
return null;
}
IdentityUserRole found = user.Roles.FirstOrDefault(x => x.RoleId.InvariantEquals(roleId));
return found;
}
private MemberIdentityUser AssignLoginsCallback(MemberIdentityUser user)
{
if (user != null)
{
user.SetLoginsCallback(new Lazy>(() => _externalLoginService.GetExternalLogins(user.Key)));
user.SetTokensCallback(new Lazy>(() => _externalLoginService.GetExternalLoginTokens(user.Key)));
}
return user;
}
private MemberDataChangeType UpdateMemberProperties(IMember member, MemberIdentityUser identityUser)
{
MemberDataChangeType changeType = MemberDataChangeType.None;
// don't assign anything if nothing has changed as this will trigger the track changes of the model
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.LastLoginDateUtc))
|| (member.LastLoginDate != default && identityUser.LastLoginDateUtc.HasValue == false)
|| (identityUser.LastLoginDateUtc.HasValue && member.LastLoginDate.ToUniversalTime() != identityUser.LastLoginDateUtc.Value))
{
changeType = MemberDataChangeType.LoginOnly;
// if the LastLoginDate is being set to MinValue, don't convert it ToLocalTime
DateTime dt = identityUser.LastLoginDateUtc == DateTime.MinValue ? DateTime.MinValue : identityUser.LastLoginDateUtc.Value.ToLocalTime();
member.LastLoginDate = dt;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.LastPasswordChangeDateUtc))
|| (member.LastPasswordChangeDate != default && identityUser.LastPasswordChangeDateUtc.HasValue == false)
|| (identityUser.LastPasswordChangeDateUtc.HasValue && member.LastPasswordChangeDate.ToUniversalTime() != identityUser.LastPasswordChangeDateUtc.Value))
{
changeType = MemberDataChangeType.FullSave;
member.LastPasswordChangeDate = identityUser.LastPasswordChangeDateUtc.Value.ToLocalTime();
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Comments))
&& member.Comments != identityUser.Comments && identityUser.Comments.IsNullOrWhiteSpace() == false)
{
changeType = MemberDataChangeType.FullSave;
member.Comments = identityUser.Comments;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.EmailConfirmed))
|| (member.EmailConfirmedDate.HasValue && member.EmailConfirmedDate.Value != default && identityUser.EmailConfirmed == false)
|| ((member.EmailConfirmedDate.HasValue == false || member.EmailConfirmedDate.Value == default) && identityUser.EmailConfirmed))
{
changeType = MemberDataChangeType.FullSave;
member.EmailConfirmedDate = identityUser.EmailConfirmed ? (DateTime?)DateTime.Now : null;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Name))
&& member.Name != identityUser.Name && identityUser.Name.IsNullOrWhiteSpace() == false)
{
changeType = MemberDataChangeType.FullSave;
member.Name = identityUser.Name;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Email))
&& member.Email != identityUser.Email && identityUser.Email.IsNullOrWhiteSpace() == false)
{
changeType = MemberDataChangeType.FullSave;
member.Email = identityUser.Email;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.AccessFailedCount))
&& member.FailedPasswordAttempts != identityUser.AccessFailedCount)
{
changeType = MemberDataChangeType.FullSave;
member.FailedPasswordAttempts = identityUser.AccessFailedCount;
}
if (member.IsLockedOut != identityUser.IsLockedOut)
{
changeType = MemberDataChangeType.FullSave;
member.IsLockedOut = identityUser.IsLockedOut;
if (member.IsLockedOut)
{
// need to set the last lockout date
member.LastLockoutDate = DateTime.Now;
}
}
if (member.IsApproved != identityUser.IsApproved)
{
changeType = MemberDataChangeType.FullSave;
member.IsApproved = identityUser.IsApproved;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.UserName))
&& member.Username != identityUser.UserName && identityUser.UserName.IsNullOrWhiteSpace() == false)
{
changeType = MemberDataChangeType.FullSave;
member.Username = identityUser.UserName;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.PasswordHash))
&& member.RawPasswordValue != identityUser.PasswordHash && identityUser.PasswordHash.IsNullOrWhiteSpace() == false)
{
changeType = MemberDataChangeType.FullSave;
member.RawPasswordValue = identityUser.PasswordHash;
member.PasswordConfiguration = identityUser.PasswordConfig;
}
if (member.PasswordConfiguration != identityUser.PasswordConfig)
{
changeType = MemberDataChangeType.FullSave;
member.PasswordConfiguration = identityUser.PasswordConfig;
}
if (member.SecurityStamp != identityUser.SecurityStamp)
{
changeType = MemberDataChangeType.FullSave;
member.SecurityStamp = identityUser.SecurityStamp;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Roles)))
{
changeType = MemberDataChangeType.FullSave;
var identityUserRoles = identityUser.Roles.Select(x => x.RoleId).ToArray();
_memberService.ReplaceRoles(new[] { member.Id }, identityUserRoles);
}
// reset all changes
identityUser.ResetDirtyProperties(false);
return changeType;
}
public IPublishedContent GetPublishedMember(MemberIdentityUser user)
{
if (user == null)
{
return null;
}
IMember member = _memberService.GetByKey(user.Key);
if (member == null)
{
return null;
}
var publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot();
return publishedSnapshot.Members.Get(member);
}
private enum MemberDataChangeType
{
None,
LoginOnly,
FullSave
}
///
/// Overridden to support Umbraco's own data storage requirements
///
///
/// The base class's implementation of this calls into FindTokenAsync, RemoveUserTokenAsync and AddUserTokenAsync, both methods will only work with ORMs that are change
/// tracking ORMs like EFCore.
///
///
public override Task GetTokenAsync(MemberIdentityUser user, string loginProvider, string name, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
IIdentityUserToken token = user.LoginTokens.FirstOrDefault(x => x.LoginProvider.InvariantEquals(loginProvider) && x.Name.InvariantEquals(name));
return Task.FromResult(token?.Value);
}
///
public override async Task GetTwoFactorEnabledAsync(MemberIdentityUser user,
CancellationToken cancellationToken = default(CancellationToken))
{
return await _twoFactorLoginService.IsTwoFactorEnabledAsync(user.Key);
}
}
}