diff --git a/src/Umbraco.Web/ClaimsIdentityExtensions.cs b/src/Umbraco.Web/ClaimsIdentityExtensions.cs
new file mode 100644
index 0000000000..c7f777f60b
--- /dev/null
+++ b/src/Umbraco.Web/ClaimsIdentityExtensions.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Security.Claims;
+using System.Security.Principal;
+
+namespace Umbraco.Web
+{
+ public static class ClaimsIdentityExtensions
+ {
+ public static string GetUserId(this IIdentity identity)
+ {
+ if (identity == null) throw new ArgumentNullException(nameof(identity));
+
+ string userId = null;
+ if (identity is ClaimsIdentity claimsIdentity)
+ {
+ userId = claimsIdentity.FindFirst(ClaimTypes.NameIdentifier)?.Value
+ ?? claimsIdentity.FindFirst("sub")?.Value;
+ }
+
+ return userId;
+ }
+
+ public static string GetUserName(this IIdentity identity)
+ {
+ if (identity == null) throw new ArgumentNullException(nameof(identity));
+
+ string username = null;
+ if (identity is ClaimsIdentity claimsIdentity)
+ {
+ username = claimsIdentity.FindFirst(ClaimTypes.Name)?.Value
+ ?? claimsIdentity.FindFirst("preferred_username")?.Value;
+ }
+
+ return username;
+ }
+ }
+}
diff --git a/src/Umbraco.Web/OwinExtensions.cs b/src/Umbraco.Web/OwinExtensions.cs
index 204df8935e..25edda2647 100644
--- a/src/Umbraco.Web/OwinExtensions.cs
+++ b/src/Umbraco.Web/OwinExtensions.cs
@@ -1,11 +1,8 @@
using System;
using System.Web;
-using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using Umbraco.Core;
-using Umbraco.Core.Models.Identity;
-using Umbraco.Core.Security;
using Umbraco.Web.Models.Identity;
using Umbraco.Web.Security;
@@ -66,6 +63,17 @@ namespace Umbraco.Web
?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager)} from the {typeof(IOwinContext)}.");
}
+ ///
+ /// Gets the back office sign in manager out of OWIN
+ ///
+ ///
+ ///
+ public static BackOfficeSignInManager2 GetBackOfficeSignInManager2(this IOwinContext owinContext)
+ {
+ return owinContext.Get()
+ ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager2)} from the {typeof(IOwinContext)}.");
+ }
+
///
/// Gets the back office user manager out of OWIN
///
@@ -84,6 +92,38 @@ namespace Umbraco.Web
?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager)} from the {typeof (IOwinContext)}.");
}
+ ///
+ /// Gets the back office user manager out of OWIN
+ ///
+ ///
+ ///
+ ///
+ /// This is required because to extract the user manager we need to user a custom service since owin only deals in generics and
+ /// developers could register their own user manager types
+ ///
+ public static BackOfficeUserManager2 GetBackOfficeUserManager2(this IOwinContext owinContext)
+ {
+ var marker = owinContext.Get(BackOfficeUserManager2.OwinMarkerKey)
+ ?? throw new NullReferenceException($"No {typeof (IBackOfficeUserManagerMarker2)}, i.e. no Umbraco back-office, has been registered with Owin.");
+
+ return marker.GetManager(owinContext)
+ ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager)} from the {typeof (IOwinContext)}.");
+ }
+
// TODO: SB: OWIN DI
+
+ ///
+ /// Adapted from Microsoft.AspNet.Identity.Owin.OwinContextExtensions
+ ///
+ public static T Get(this IOwinContext context)
+ {
+ if (context == null) throw new ArgumentNullException(nameof(context));
+ return context.Get(GetKey(typeof(T)));
+ }
+
+ private static string GetKey(Type t)
+ {
+ return "AspNet.Identity.Owin:" + t.AssemblyQualifiedName;
+ }
}
}
diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs
index 2506613276..fe5b061d15 100644
--- a/src/Umbraco.Web/Security/BackOfficeSignInManager.cs
+++ b/src/Umbraco.Web/Security/BackOfficeSignInManager.cs
@@ -16,19 +16,6 @@ using Constants = Umbraco.Core.Constants;
namespace Umbraco.Web.Security
{
- // TODO: SB: Create new custom BackOfficeSignInManager2 (rename pending)
- public class BackOfficeSignInManager2
- {
- // Create
- // CreateUserIdentityAsync
- // PasswordSignInAsync
- // SignInAsync
- // GetVerifiedUserIdAsync
- // GetVerifiedUserNameAsync
- // TwoFactorSignInAsync
- // SendTwoFactorCodeAsync
- }
-
// TODO: In v8 we need to change this to use an int? nullable TKey instead, see notes against overridden TwoFactorSignInAsync
public class BackOfficeSignInManager : SignInManager
{
diff --git a/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs b/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs
new file mode 100644
index 0000000000..ac33eb208d
--- /dev/null
+++ b/src/Umbraco.Web/Security/BackOfficeSignInManager2.cs
@@ -0,0 +1,354 @@
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Owin;
+using Microsoft.Owin.Logging;
+using Microsoft.Owin.Security;
+using Umbraco.Core;
+using Umbraco.Core.Configuration;
+using Umbraco.Core.Security;
+using Umbraco.Web.Models.Identity;
+
+namespace Umbraco.Web.Security
+{
+ ///
+ /// Custom sign in manager due to SignInManager not being .NET Standard.
+ /// Can be removed once the web project moves to .NET Core.
+ ///
+ public class BackOfficeSignInManager2
+ {
+ private readonly BackOfficeUserManager2 _userManager;
+ private readonly IAuthenticationManager _authenticationManager;
+ private readonly ILogger _logger;
+ private readonly IGlobalSettings _globalSettings;
+ private readonly IOwinRequest _request;
+
+ public BackOfficeSignInManager2(
+ BackOfficeUserManager2 userManager,
+ IAuthenticationManager authenticationManager,
+ ILogger logger,
+ IGlobalSettings globalSettings,
+ IOwinRequest request)
+ {
+ _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager));
+ _authenticationManager = authenticationManager ?? throw new ArgumentNullException(nameof(authenticationManager));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _globalSettings = globalSettings ?? throw new ArgumentNullException(nameof(globalSettings));
+ _request = request ?? throw new ArgumentNullException(nameof(request));
+ }
+
+ public Task CreateUserIdentityAsync(BackOfficeIdentityUser user)
+ {
+ throw new NotImplementedException();
+ }
+
+ public static BackOfficeSignInManager2 Create(IOwinContext context, IGlobalSettings globalSettings, ILogger logger)
+ {
+ return new BackOfficeSignInManager2(
+ context.GetBackOfficeUserManager2(),
+ context.Authentication,
+ logger,
+ globalSettings,
+ context.Request);
+ }
+
+ ///
+ /// Sign in the user in using the user name and password
+ ///
+ ///
+ ///
+ public async Task PasswordSignInAsync(string userName, string password, bool isPersistent, bool shouldLockout)
+ {
+ var result = await PasswordSignInAsyncImpl(userName, password, isPersistent, shouldLockout);
+
+ if (result.Succeeded)
+ {
+ _logger.WriteCore(TraceEventType.Information, 0,
+ $"User: {userName} logged in from IP address {_request.RemoteIpAddress}", null, null);
+ }
+ else if (result.IsLockedOut)
+ {
+ _logger.WriteCore(TraceEventType.Information, 0,
+ $"Login attempt failed for username {userName} from IP address {_request.RemoteIpAddress}, the user is locked", null, null);
+ }
+ else if (result.RequiresTwoFactor)
+ {
+ _logger.WriteCore(TraceEventType.Information, 0,
+ $"Login attempt requires verification for username {userName} from IP address {_request.RemoteIpAddress}", null, null);
+ }
+ else if (!result.Succeeded || result.IsNotAllowed)
+ {
+ _logger.WriteCore(TraceEventType.Information, 0,
+ $"Login attempt failed for username {userName} from IP address {_request.RemoteIpAddress}", null, null);
+ }
+ else
+ {
+ throw new ArgumentOutOfRangeException();
+ }
+
+ return result;
+ }
+
+ ///
+ /// Borrowed from Microsoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ private async Task PasswordSignInAsyncImpl(string userName, string password, bool isPersistent, bool shouldLockout)
+ {
+ var user = await _userManager.FindByNameAsync(userName);
+
+ //if the user is null, create an empty one which can be used for auto-linking
+ if (user == null) user = BackOfficeIdentityUser.CreateNew(_globalSettings, userName, null, _globalSettings.DefaultUILanguage);
+
+ //check the password for the user, this will allow a developer to auto-link
+ //an account if they have specified an IBackOfficeUserPasswordChecker
+ if (await _userManager.CheckPasswordAsync(user, password))
+ {
+ //the underlying call to this will query the user by Id which IS cached!
+ if (await _userManager.IsLockedOutAsync(user))
+ {
+ return SignInResult.LockedOut;
+ }
+
+ // We need to verify that the user belongs to one or more groups that define content and media start nodes.
+ // To do so we have to create the user claims identity and validate the calculated start nodes.
+ var userIdentity = await CreateUserIdentityAsync(user);
+ if (userIdentity is UmbracoBackOfficeIdentity backOfficeIdentity)
+ {
+ if (backOfficeIdentity.StartContentNodes.Length == 0 || backOfficeIdentity.StartMediaNodes.Length == 0)
+ {
+ _logger.WriteCore(TraceEventType.Information, 0,
+ $"Login attempt failed for username {userName} from IP address {_request.RemoteIpAddress}, no content and/or media start nodes could be found for any of the user's groups", null, null);
+ return SignInResult.Failed;
+ }
+ }
+
+ await _userManager.ResetAccessFailedCountAsync(user);
+ return await SignInOrTwoFactor(user, isPersistent);
+ }
+
+ var requestContext = _request.Context;
+
+ if (user.HasIdentity && shouldLockout)
+ {
+ // If lockout is requested, increment access failed count which might lock out the user
+ await _userManager.AccessFailedAsync(user);
+ if (await _userManager.IsLockedOutAsync(user))
+ {
+ //at this point we've just locked the user out after too many failed login attempts
+
+ if (requestContext != null)
+ {
+ var backofficeUserManager = requestContext.GetBackOfficeUserManager();
+ if (backofficeUserManager != null) backofficeUserManager.RaiseAccountLockedEvent(user.Id);
+ }
+
+ return SignInResult.LockedOut;
+ }
+ }
+
+ if (requestContext != null)
+ {
+ var backofficeUserManager = requestContext.GetBackOfficeUserManager();
+ if (backofficeUserManager != null)
+ backofficeUserManager.RaiseInvalidLoginAttemptEvent(userName);
+ }
+
+ return SignInResult.Failed;
+ }
+
+ ///
+ /// Borrowed from Microsoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type
+ ///
+ ///
+ ///
+ ///
+ private async Task SignInOrTwoFactor(BackOfficeIdentityUser user, bool isPersistent)
+ {
+ var id = Convert.ToString(user.Id);
+ if (await _userManager.GetTwoFactorEnabledAsync(user)
+ && (await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0)
+ {
+ var identity = new ClaimsIdentity(Constants.Security.BackOfficeTwoFactorAuthenticationType);
+ identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, id));
+ identity.AddClaim(new Claim(ClaimsIdentity.DefaultNameClaimType, user.UserName));
+ _authenticationManager.SignIn(identity);
+ return SignInResult.TwoFactorRequired;
+ }
+ await SignInAsync(user, isPersistent, false);
+ return SignInResult.Success;
+ }
+
+ ///
+ /// Creates a user identity and then signs the identity using the AuthenticationManager
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task SignInAsync(BackOfficeIdentityUser user, bool isPersistent, bool rememberBrowser)
+ {
+ var userIdentity = await CreateUserIdentityAsync(user);
+
+ // Clear any partial cookies from external or two factor partial sign ins
+ _authenticationManager.SignOut(
+ Constants.Security.BackOfficeExternalAuthenticationType,
+ Constants.Security.BackOfficeTwoFactorAuthenticationType);
+
+ var nowUtc = DateTime.Now.ToUniversalTime();
+
+ if (rememberBrowser)
+ {
+ var rememberBrowserIdentity = _authenticationManager.CreateTwoFactorRememberBrowserIdentity(ConvertIdToString(user.Id));
+ _authenticationManager.SignIn(new AuthenticationProperties()
+ {
+ IsPersistent = isPersistent,
+ AllowRefresh = true,
+ IssuedUtc = nowUtc,
+ ExpiresUtc = nowUtc.AddMinutes(_globalSettings.TimeOutInMinutes)
+ }, userIdentity, rememberBrowserIdentity);
+ }
+ else
+ {
+ _authenticationManager.SignIn(new AuthenticationProperties()
+ {
+ IsPersistent = isPersistent,
+ AllowRefresh = true,
+ IssuedUtc = nowUtc,
+ ExpiresUtc = nowUtc.AddMinutes(_globalSettings.TimeOutInMinutes)
+ }, userIdentity);
+ }
+
+ //track the last login date
+ user.LastLoginDateUtc = DateTime.UtcNow;
+ if (user.AccessFailedCount > 0)
+ //we have successfully logged in, reset the AccessFailedCount
+ user.AccessFailedCount = 0;
+ await _userManager.UpdateAsync(user);
+
+ //set the current request's principal to the identity just signed in!
+ _request.User = new ClaimsPrincipal(userIdentity);
+
+ _logger.WriteCore(TraceEventType.Information, 0,
+ string.Format(
+ "Login attempt succeeded for username {0} from IP address {1}",
+ user.UserName,
+ _request.RemoteIpAddress), null, null);
+ }
+
+ ///
+ /// Get the user id that has been verified already or int.MinValue if the user has not been verified yet
+ ///
+ ///
+ ///
+ /// Replaces the underlying call which is not flexible and doesn't support a custom cookie
+ ///
+ public async Task GetVerifiedUserIdAsync()
+ {
+ var result = await _authenticationManager.AuthenticateAsync(Constants.Security.BackOfficeTwoFactorAuthenticationType);
+ if (result != null && result.Identity != null && string.IsNullOrEmpty(result.Identity.GetUserId()) == false)
+ {
+ return ConvertIdFromString(result.Identity.GetUserId());
+ }
+ return int.MinValue;
+ }
+
+ ///
+ /// Get the username that has been verified already or null.
+ ///
+ ///
+ public async Task GetVerifiedUserNameAsync()
+ {
+ var result = await _authenticationManager.AuthenticateAsync(Constants.Security.BackOfficeTwoFactorAuthenticationType);
+ if (result != null && result.Identity != null && string.IsNullOrEmpty(result.Identity.GetUserName()) == false)
+ {
+ return result.Identity.GetUserName();
+ }
+ return null;
+ }
+
+ ///
+ /// Two factor verification step
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// This is implemented because we cannot override GetVerifiedUserIdAsync and instead we have to shadow it
+ /// so due to this and because we are using an INT as the TKey and not an object, it can never be null. Adding to that
+ /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate
+ /// all of this code to check for int.MinValue
+ ///
+ public async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberBrowser)
+ {
+ var userId = await GetVerifiedUserIdAsync();
+ if (userId == int.MinValue)
+ {
+ return SignInResult.Failed;
+ }
+ var user = await _userManager.FindByIdAsync(ConvertIdToString(userId));
+ if (user == null)
+ {
+ return SignInResult.Failed;
+ }
+ if (await _userManager.IsLockedOutAsync(user))
+ {
+ return SignInResult.LockedOut;
+ }
+ if (await _userManager.VerifyTwoFactorTokenAsync(user, provider, code))
+ {
+ // When token is verified correctly, clear the access failed count used for lockout
+ await _userManager.ResetAccessFailedCountAsync(user);
+ await SignInAsync(user, isPersistent, rememberBrowser);
+ return SignInResult.Success;
+ }
+
+ // If the token is incorrect, record the failure which also may cause the user to be locked out
+ await _userManager.AccessFailedAsync(user);
+ return SignInResult.Failed;
+ }
+
+ /// Send a two factor code to a user
+ ///
+ ///
+ ///
+ /// This is implemented because we cannot override GetVerifiedUserIdAsync and instead we have to shadow it
+ /// so due to this and because we are using an INT as the TKey and not an object, it can never be null. Adding to that
+ /// the default(int) value returned by the base class is always a valid user (i.e. the admin) so we just have to duplicate
+ /// all of this code to check for int.MinVale instead.
+ ///
+ public async Task SendTwoFactorCodeAsync(string provider)
+ {
+ throw new NotImplementedException();
+
+ /*var userId = await GetVerifiedUserIdAsync();
+ if (userId == int.MinValue)
+ return false;
+
+ var token = await _userManager.GenerateTwoFactorTokenAsync(userId, provider);
+
+
+ var identityResult = await _userManager.NotifyTwoFactorTokenAsync(userId, provider, token);
+ return identityResult.Succeeded;*/
+ }
+
+ private string ConvertIdToString(int id)
+ {
+ return Convert.ToString(id, CultureInfo.InvariantCulture);
+ }
+
+ private int ConvertIdFromString(string id)
+ {
+ return id == null ? default(int) : (int) Convert.ChangeType(id, typeof(int), CultureInfo.InvariantCulture);
+ }
+ }
+}
diff --git a/src/Umbraco.Web/Security/BackOfficeUserManager2.cs b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs
new file mode 100644
index 0000000000..b22f9523c0
--- /dev/null
+++ b/src/Umbraco.Web/Security/BackOfficeUserManager2.cs
@@ -0,0 +1,615 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Umbraco.Core.Configuration;
+using Umbraco.Core.Mapping;
+using Umbraco.Core.Security;
+using Umbraco.Core.Services;
+using Umbraco.Net;
+using Umbraco.Web.Models.Identity;
+
+namespace Umbraco.Web.Security
+{
+ public class BackOfficeUserManager2 : BackOfficeUserManager2
+ {
+ public const string OwinMarkerKey = "Umbraco.Web.Security.Identity.BackOfficeUserManagerMarker";
+
+ public BackOfficeUserManager2(
+ IPasswordConfiguration passwordConfiguration,
+ IIpResolver ipResolver,
+ IUserStore store,
+ IOptions optionsAccessor,
+ IPasswordHasher passwordHasher,
+ IEnumerable> userValidators,
+ IEnumerable> passwordValidators,
+ ILookupNormalizer keyNormalizer,
+ IdentityErrorDescriber errors,
+ IServiceProvider services,
+ ILogger> logger)
+ : base(passwordConfiguration, ipResolver, store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger)
+ {
+ InitUserManager(this, passwordConfiguration);
+ }
+
+ #region Static Create methods
+
+ // TODO: SB: Static Create methods for OWIN
+
+ ///
+ /// Creates a BackOfficeUserManager instance with all default options and the default BackOfficeUserManager
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static BackOfficeUserManager2 Create(
+ IUserService userService,
+ IEntityService entityService,
+ IExternalLoginService externalLoginService,
+ IGlobalSettings globalSettings,
+ UmbracoMapper mapper,
+ IPasswordConfiguration passwordConfiguration,
+ IIpResolver ipResolver,
+ IOptions optionsAccessor,
+ IPasswordHasher passwordHasher,
+ IEnumerable> userValidators,
+ IEnumerable> passwordValidators,
+ ILookupNormalizer keyNormalizer,
+ IdentityErrorDescriber errors,
+ IServiceProvider services,
+ ILogger> logger)
+ {
+ var store = new BackOfficeUserStore2(userService, entityService, externalLoginService, globalSettings, mapper);
+ return new BackOfficeUserManager2(
+ passwordConfiguration,
+ ipResolver,
+ store,
+ optionsAccessor,
+ passwordHasher,
+ userValidators,
+ passwordValidators,
+ keyNormalizer,
+ errors,
+ services,
+ logger);
+ }
+
+ ///
+ /// Creates a BackOfficeUserManager instance with all default options and a custom BackOfficeUserManager instance
+ ///
+ ///
+ public static BackOfficeUserManager2 Create(
+ IPasswordConfiguration passwordConfiguration,
+ IIpResolver ipResolver,
+ IUserStore store,
+ IOptions optionsAccessor,
+ IPasswordHasher passwordHasher,
+ IEnumerable> userValidators,
+ IEnumerable> passwordValidators,
+ ILookupNormalizer keyNormalizer,
+ IdentityErrorDescriber errors,
+ IServiceProvider services,
+ ILogger> logger)
+ {
+ return new BackOfficeUserManager2(
+ passwordConfiguration,
+ ipResolver,
+ store,
+ optionsAccessor,
+ passwordHasher,
+ userValidators,
+ passwordValidators,
+ keyNormalizer,
+ errors,
+ services,
+ logger);
+ }
+
+ #endregion
+ }
+
+ public class BackOfficeUserManager2 : UserManager
+ where T : BackOfficeIdentityUser
+ {
+ private PasswordGenerator _passwordGenerator;
+
+ public BackOfficeUserManager2(
+ IPasswordConfiguration passwordConfiguration,
+ IIpResolver ipResolver,
+ IUserStore store,
+ IOptions optionsAccessor,
+ IPasswordHasher passwordHasher,
+ IEnumerable> userValidators,
+ IEnumerable> passwordValidators,
+ ILookupNormalizer keyNormalizer,
+ IdentityErrorDescriber errors,
+ IServiceProvider services,
+ ILogger> logger)
+ : base(store, optionsAccessor, passwordHasher, 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
+
+ // TODO: SB: INIT
+ ///
+ /// Initializes the user manager with the correct options
+ ///
+ ///
+ ///
+ ///
+ protected void InitUserManager(
+ BackOfficeUserManager2 manager,
+ IPasswordConfiguration passwordConfig)
+ // IDataProtectionProvider dataProtectionProvider
+ {
+ // Configure validation logic for usernames
+ manager.UserValidators.Clear();
+ manager.UserValidators.Add(new BackOfficeUserValidator2());
+ manager.Options.User.RequireUniqueEmail = true;
+
+ // Configure validation logic for passwords
+ manager.PasswordValidators.Clear();
+ manager.PasswordValidators.Add(new PasswordValidator());
+ manager.Options.Password.RequiredLength = passwordConfig.RequiredLength;
+ manager.Options.Password.RequireNonAlphanumeric = passwordConfig.RequireNonLetterOrDigit;
+ manager.Options.Password.RequireDigit = passwordConfig.RequireDigit;
+ manager.Options.Password.RequireLowercase = passwordConfig.RequireLowercase;
+ manager.Options.Password.RequireUppercase = passwordConfig.RequireUppercase;
+
+ //use a custom hasher based on our membership provider
+ manager.PasswordHasher = GetDefaultPasswordHasher(passwordConfig);
+
+ // TODO: SB: manager.Options.Tokens using OWIN data protector
+ /*if (dataProtectionProvider != null)
+ {
+ manager.UserTokenProvider = new DataProtectorTokenProvider(dataProtectionProvider.Create("ASP.NET Identity"))
+ {
+ TokenLifespan = TimeSpan.FromDays(3)
+ };
+ }*/
+
+ manager.Options.Lockout.AllowedForNewUsers = true;
+ manager.Options.Lockout.MaxFailedAccessAttempts = passwordConfig.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.
+ manager.Options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(30);
+ }
+
+ ///
+ /// Used to validate a user's session
+ ///
+ ///
+ ///
+ ///
+ public virtual async Task ValidateSessionIdAsync(int userId, string sessionId)
+ {
+ var userSessionStore = Store as IUserSessionStore2;
+ //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 UserAwarePasswordHasher2(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());
+ 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
+ ///
+ ///
+ ///
+ ///
+ ///
+ protected override async Task VerifyPasswordAsync(IUserPasswordStore store, T user, string password)
+ {
+ var userAwarePasswordHasher = PasswordHasher;
+ if (userAwarePasswordHasher == null)
+ return await base.VerifyPasswordAsync(store, user, password);
+
+ var hash = await store.GetPasswordHashAsync(user, CancellationToken.None);
+ return userAwarePasswordHasher.VerifyHashedPassword(user, hash, password);
+ }
+
+ ///
+ /// 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/Security/BackOfficeUserValidator2.cs b/src/Umbraco.Web/Security/BackOfficeUserValidator2.cs
new file mode 100644
index 0000000000..b6438d730d
--- /dev/null
+++ b/src/Umbraco.Web/Security/BackOfficeUserValidator2.cs
@@ -0,0 +1,20 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Umbraco.Web.Models.Identity;
+
+namespace Umbraco.Web.Security
+{
+ public class BackOfficeUserValidator2 : 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/Security/IBackOfficeUserManagerMarker.cs b/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs
index b1f3530879..92fea0bf40 100644
--- a/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs
+++ b/src/Umbraco.Web/Security/IBackOfficeUserManagerMarker.cs
@@ -13,4 +13,13 @@ namespace Umbraco.Web.Security
{
BackOfficeUserManager GetManager(IOwinContext owin);
}
+ ///
+ /// This interface is only here due to the fact that IOwinContext Get / Set only work in generics, if they worked
+ /// with regular 'object' then we wouldn't have to use this work around but because of that we have to use this
+ /// class to resolve the 'real' type of the registered user manager
+ ///
+ internal interface IBackOfficeUserManagerMarker2
+ {
+ BackOfficeUserManager2 GetManager(IOwinContext owin);
+ }
}
diff --git a/src/Umbraco.Web/Security/UserAwarePasswordHasher2.cs b/src/Umbraco.Web/Security/UserAwarePasswordHasher2.cs
new file mode 100644
index 0000000000..9a44533427
--- /dev/null
+++ b/src/Umbraco.Web/Security/UserAwarePasswordHasher2.cs
@@ -0,0 +1,40 @@
+using Microsoft.AspNetCore.Identity;
+using Umbraco.Core.Security;
+using Umbraco.Web.Models.Identity;
+
+namespace Umbraco.Web.Security
+{
+ public class UserAwarePasswordHasher2 : IPasswordHasher
+ where T : BackOfficeIdentityUser
+ {
+ private readonly PasswordSecurity _passwordSecurity;
+
+ public UserAwarePasswordHasher2(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;
+ }
+ }
+}
diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj
index caa38e56d8..aed9a34b82 100755
--- a/src/Umbraco.Web/Umbraco.Web.csproj
+++ b/src/Umbraco.Web/Umbraco.Web.csproj
@@ -135,6 +135,7 @@
+
@@ -197,9 +198,12 @@
+
+
+
@@ -211,6 +215,7 @@
+
diff --git a/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs b/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs
index ff24a2a6f5..985343946e 100644
--- a/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs
+++ b/src/Umbraco.Web/WebApi/UmbracoAuthorizedApiController.cs
@@ -2,8 +2,7 @@
using Umbraco.Core.Cache;
using Umbraco.Core.Configuration;
using Umbraco.Core.Logging;
-using Umbraco.Web.WebApi.Filters;
-using Umbraco.Core.Models.Identity;
+using Umbraco.Web.WebApi.Filters;using Umbraco.Core.Models.Identity;
using Umbraco.Core.Persistence;
using Umbraco.Core.Services;
using Umbraco.Web.Models.Identity;
@@ -31,7 +30,7 @@ namespace Umbraco.Web.WebApi
[EnableDetailedErrors]
public abstract class UmbracoAuthorizedApiController : UmbracoApiController
{
- private BackOfficeUserManager _userManager;
+ private BackOfficeUserManager2 _userManager;
protected UmbracoAuthorizedApiController()
{
@@ -45,7 +44,7 @@ namespace Umbraco.Web.WebApi
///
/// Gets the user manager.
///
- protected BackOfficeUserManager UserManager
- => _userManager ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager());
+ protected BackOfficeUserManager2 UserManager
+ => _userManager ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager2());
}
}