Initial side by side implementation

This commit is contained in:
Scott Brady
2020-03-13 15:15:14 +00:00
parent 4e69e1abe6
commit 2e7e6bc4a9
10 changed files with 1127 additions and 21 deletions

View File

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

View File

@@ -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)}.");
}
/// <summary>
/// Gets the back office sign in manager out of OWIN
/// </summary>
/// <param name="owinContext"></param>
/// <returns></returns>
public static BackOfficeSignInManager2 GetBackOfficeSignInManager2(this IOwinContext owinContext)
{
return owinContext.Get<BackOfficeSignInManager2>()
?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeSignInManager2)} from the {typeof(IOwinContext)}.");
}
/// <summary>
/// Gets the back office user manager out of OWIN
/// </summary>
@@ -84,6 +92,38 @@ namespace Umbraco.Web
?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager<BackOfficeIdentityUser>)} from the {typeof (IOwinContext)}.");
}
/// <summary>
/// Gets the back office user manager out of OWIN
/// </summary>
/// <param name="owinContext"></param>
/// <returns></returns>
/// <remarks>
/// 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
/// </remarks>
public static BackOfficeUserManager2<BackOfficeIdentityUser> GetBackOfficeUserManager2(this IOwinContext owinContext)
{
var marker = owinContext.Get<IBackOfficeUserManagerMarker2>(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<BackOfficeIdentityUser>)} from the {typeof (IOwinContext)}.");
}
// TODO: SB: OWIN DI
/// <summary>
/// Adapted from Microsoft.AspNet.Identity.Owin.OwinContextExtensions
/// </summary>
public static T Get<T>(this IOwinContext context)
{
if (context == null) throw new ArgumentNullException(nameof(context));
return context.Get<T>(GetKey(typeof(T)));
}
private static string GetKey(Type t)
{
return "AspNet.Identity.Owin:" + t.AssemblyQualifiedName;
}
}
}

View File

@@ -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<BackOfficeIdentityUser, int>
{

View File

@@ -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
{
/// <summary>
/// Custom sign in manager due to SignInManager not being .NET Standard.
/// Can be removed once the web project moves to .NET Core.
/// </summary>
public class BackOfficeSignInManager2
{
private readonly BackOfficeUserManager2<BackOfficeIdentityUser> _userManager;
private readonly IAuthenticationManager _authenticationManager;
private readonly ILogger _logger;
private readonly IGlobalSettings _globalSettings;
private readonly IOwinRequest _request;
public BackOfficeSignInManager2(
BackOfficeUserManager2<BackOfficeIdentityUser> 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<ClaimsIdentity> 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);
}
/// <summary>
/// Sign in the user in using the user name and password
/// </summary>
/// <param name="userName"/><param name="password"/><param name="isPersistent"/><param name="shouldLockout"/>
/// <returns/>
public async Task<SignInResult> 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;
}
/// <summary>
/// Borrowed from Microsoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type
/// </summary>
/// <param name="userName"></param>
/// <param name="password"></param>
/// <param name="isPersistent"></param>
/// <param name="shouldLockout"></param>
/// <returns></returns>
private async Task<SignInResult> 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;
}
/// <summary>
/// Borrowed from Microsoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type
/// </summary>
/// <param name="user"></param>
/// <param name="isPersistent"></param>
/// <returns></returns>
private async Task<SignInResult> 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;
}
/// <summary>
/// Creates a user identity and then signs the identity using the AuthenticationManager
/// </summary>
/// <param name="user"></param>
/// <param name="isPersistent"></param>
/// <param name="rememberBrowser"></param>
/// <returns></returns>
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);
}
/// <summary>
/// Get the user id that has been verified already or int.MinValue if the user has not been verified yet
/// </summary>
/// <returns></returns>
/// <remarks>
/// Replaces the underlying call which is not flexible and doesn't support a custom cookie
/// </remarks>
public async Task<int> 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;
}
/// <summary>
/// Get the username that has been verified already or null.
/// </summary>
/// <returns></returns>
public async Task<string> 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;
}
/// <summary>
/// Two factor verification step
/// </summary>
/// <param name="provider"></param>
/// <param name="code"></param>
/// <param name="isPersistent"></param>
/// <param name="rememberBrowser"></param>
/// <returns></returns>
/// <remarks>
/// 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
/// </remarks>
public async Task<SignInResult> 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;
}
/// <summary>Send a two factor code to a user</summary>
/// <param name="provider"></param>
/// <returns></returns>
/// <remarks>
/// 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.
/// </remarks>
public async Task<bool> 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);
}
}
}

View File

@@ -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<BackOfficeIdentityUser>
{
public const string OwinMarkerKey = "Umbraco.Web.Security.Identity.BackOfficeUserManagerMarker";
public BackOfficeUserManager2(
IPasswordConfiguration passwordConfiguration,
IIpResolver ipResolver,
IUserStore<BackOfficeIdentityUser> store,
IOptions<IdentityOptions> optionsAccessor,
IPasswordHasher<BackOfficeIdentityUser> passwordHasher,
IEnumerable<IUserValidator<BackOfficeIdentityUser>> userValidators,
IEnumerable<IPasswordValidator<BackOfficeIdentityUser>> passwordValidators,
ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors,
IServiceProvider services,
ILogger<UserManager<BackOfficeIdentityUser>> 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
/// <summary>
/// Creates a BackOfficeUserManager instance with all default options and the default BackOfficeUserManager
/// </summary>
/// <param name="options"></param>
/// <param name="userService"></param>
/// <param name="entityService"></param>
/// <param name="externalLoginService"></param>
/// <param name="passwordConfiguration"></param>
/// <param name="contentSectionConfig"></param>
/// <param name="globalSettings"></param>
/// <returns></returns>
public static BackOfficeUserManager2 Create(
IUserService userService,
IEntityService entityService,
IExternalLoginService externalLoginService,
IGlobalSettings globalSettings,
UmbracoMapper mapper,
IPasswordConfiguration passwordConfiguration,
IIpResolver ipResolver,
IOptions<IdentityOptions> optionsAccessor,
IPasswordHasher<BackOfficeIdentityUser> passwordHasher,
IEnumerable<IUserValidator<BackOfficeIdentityUser>> userValidators,
IEnumerable<IPasswordValidator<BackOfficeIdentityUser>> passwordValidators,
ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors,
IServiceProvider services,
ILogger<UserManager<BackOfficeIdentityUser>> logger)
{
var store = new BackOfficeUserStore2(userService, entityService, externalLoginService, globalSettings, mapper);
return new BackOfficeUserManager2(
passwordConfiguration,
ipResolver,
store,
optionsAccessor,
passwordHasher,
userValidators,
passwordValidators,
keyNormalizer,
errors,
services,
logger);
}
/// <summary>
/// Creates a BackOfficeUserManager instance with all default options and a custom BackOfficeUserManager instance
/// </summary>
/// <returns></returns>
public static BackOfficeUserManager2 Create(
IPasswordConfiguration passwordConfiguration,
IIpResolver ipResolver,
IUserStore<BackOfficeIdentityUser> store,
IOptions<IdentityOptions> optionsAccessor,
IPasswordHasher<BackOfficeIdentityUser> passwordHasher,
IEnumerable<IUserValidator<BackOfficeIdentityUser>> userValidators,
IEnumerable<IPasswordValidator<BackOfficeIdentityUser>> passwordValidators,
ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors,
IServiceProvider services,
ILogger<UserManager<BackOfficeIdentityUser>> logger)
{
return new BackOfficeUserManager2(
passwordConfiguration,
ipResolver,
store,
optionsAccessor,
passwordHasher,
userValidators,
passwordValidators,
keyNormalizer,
errors,
services,
logger);
}
#endregion
}
public class BackOfficeUserManager2<T> : UserManager<T>
where T : BackOfficeIdentityUser
{
private PasswordGenerator _passwordGenerator;
public BackOfficeUserManager2(
IPasswordConfiguration passwordConfiguration,
IIpResolver ipResolver,
IUserStore<T> store,
IOptions<IdentityOptions> optionsAccessor,
IPasswordHasher<T> passwordHasher,
IEnumerable<IUserValidator<T>> userValidators,
IEnumerable<IPasswordValidator<T>> passwordValidators,
ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors,
IServiceProvider services,
ILogger<UserManager<T>> 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;
/// <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
// TODO: SB: INIT
/// <summary>
/// Initializes the user manager with the correct options
/// </summary>
/// <param name="manager"></param>
/// <param name="passwordConfig"></param>
/// <returns></returns>
protected void InitUserManager(
BackOfficeUserManager2<T> manager,
IPasswordConfiguration passwordConfig)
// IDataProtectionProvider dataProtectionProvider
{
// Configure validation logic for usernames
manager.UserValidators.Clear();
manager.UserValidators.Add(new BackOfficeUserValidator2<T>());
manager.Options.User.RequireUniqueEmail = true;
// Configure validation logic for passwords
manager.PasswordValidators.Clear();
manager.PasswordValidators.Add(new PasswordValidator<T>());
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<T, int>(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);
}
/// <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(int userId, string sessionId)
{
var userSessionStore = Store as IUserSessionStore2<T, int>;
//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 UserAwarePasswordHasher2<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());
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="store"></param>
/// <param name="user"></param>
/// <param name="password"></param>
/// <returns></returns>
protected override async Task<PasswordVerificationResult> VerifyPasswordAsync(IUserPasswordStore<T> 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);
}
/// <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,20 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Umbraco.Web.Models.Identity;
namespace Umbraco.Web.Security
{
public class BackOfficeUserValidator2<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

@@ -13,4 +13,13 @@ namespace Umbraco.Web.Security
{
BackOfficeUserManager<BackOfficeIdentityUser> GetManager(IOwinContext owin);
}
/// <summary>
/// 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
/// </summary>
internal interface IBackOfficeUserManagerMarker2
{
BackOfficeUserManager2<BackOfficeIdentityUser> GetManager(IOwinContext owin);
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Identity;
using Umbraco.Core.Security;
using Umbraco.Web.Models.Identity;
namespace Umbraco.Web.Security
{
public class UserAwarePasswordHasher2<T> : IPasswordHasher<T>
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;
}
}
}

View File

@@ -135,6 +135,7 @@
<Compile Include="AspNet\AspNetHostingEnvironment.cs" />
<Compile Include="AspNet\FrameworkMarchal.cs" />
<Compile Include="Cache\WebCachingAppCache.cs" />
<Compile Include="ClaimsIdentityExtensions.cs" />
<Compile Include="Compose\AuditEventsComponent.cs" />
<Compile Include="Compose\AuditEventsComposer.cs" />
<Compile Include="Compose\BackOfficeUserAuditEventsComponent.cs" />
@@ -197,9 +198,12 @@
<Compile Include="Runtime\AspNetUmbracoBootPermissionChecker.cs" />
<Compile Include="Scheduling\SchedulerComponent.cs" />
<Compile Include="Scheduling\SchedulerComposer.cs" />
<Compile Include="Security\BackOfficeSignInManager2.cs" />
<Compile Include="Security\BackOfficeUserManager2.cs" />
<Compile Include="Security\BackOfficeUserStore.cs" />
<Compile Include="Security\BackOfficeUserStore2.cs" />
<Compile Include="Security\BackOfficeUserValidator.cs" />
<Compile Include="Security\BackOfficeUserValidator2.cs" />
<Compile Include="Security\ConfiguredPasswordValidator.cs" />
<Compile Include="Security\EmailService.cs" />
<Compile Include="Security\IUserAwarePasswordHasher.cs" />
@@ -211,6 +215,7 @@
<Compile Include="Security\UmbracoEmailMessage.cs" />
<Compile Include="Security\UmbracoMembershipProviderBase.cs" />
<Compile Include="Security\UserAwarePasswordHasher.cs" />
<Compile Include="Security\UserAwarePasswordHasher2.cs" />
<Compile Include="StringExtensions.cs" />
<Compile Include="Trees\TreeCollectionBuilder.cs" />
<Compile Include="UmbracoContext.cs" />

View File

@@ -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<BackOfficeIdentityUser> _userManager;
private BackOfficeUserManager2<BackOfficeIdentityUser> _userManager;
protected UmbracoAuthorizedApiController()
{
@@ -45,7 +44,7 @@ namespace Umbraco.Web.WebApi
/// <summary>
/// Gets the user manager.
/// </summary>
protected BackOfficeUserManager<BackOfficeIdentityUser> UserManager
=> _userManager ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager());
protected BackOfficeUserManager2<BackOfficeIdentityUser> UserManager
=> _userManager ?? (_userManager = TryGetOwinContext().Result.GetBackOfficeUserManager2());
}
}