From 5f4818263f72a77c8279d34d17ad7dd6676f2388 Mon Sep 17 00:00:00 2001 From: Shannon Date: Mon, 29 Mar 2021 17:37:58 +1100 Subject: [PATCH] Fixes BackOfficeClaimsPrincipalFactory to use the correct auth type. Uses the correct UmbracoIdentityRole class, fixes up MemberSignInManagerTests, new MemberClaimsPrincipalFactory --- .../BackOfficeClaimsPrincipalFactory.cs | 45 +- .../Security/ClaimsIdentityExtensions.cs | 9 + .../Security/MemberUserStore.cs | 8 +- .../Security/UmbracoIdentityRole.cs | 8 + .../Security/MemberSignInManagerTests.cs | 51 +- .../ServiceCollectionExtensions.cs | 8 +- .../Security/MemberClaimsPrincipalFactory.cs | 15 + .../Security/MemberManager.cs | 1 + .../Security/MemberSignInManager.cs | 21 - .../Security/UmbracoSignInManager.cs | 453 ++++++++++++++++++ 10 files changed, 552 insertions(+), 67 deletions(-) create mode 100644 src/Umbraco.Web.Common/Security/MemberClaimsPrincipalFactory.cs create mode 100644 src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeClaimsPrincipalFactory.cs b/src/Umbraco.Infrastructure/Security/BackOfficeClaimsPrincipalFactory.cs index 2bb9b1ab8d..9c6aeca80e 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeClaimsPrincipalFactory.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeClaimsPrincipalFactory.cs @@ -23,19 +23,28 @@ namespace Umbraco.Cms.Core.Security { } - /// - /// - /// Returns a ClaimsIdentity that has the required claims, and allows flowing of claims from external identity - /// - public override async Task CreateAsync(BackOfficeIdentityUser user) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } + protected virtual string AuthenticationType { get; } = Constants.Security.BackOfficeAuthenticationType; + /// + protected override async Task GenerateClaimsAsync(BackOfficeIdentityUser user) + { + // NOTE: Have a look at the base implementation https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Extensions.Core/src/UserClaimsPrincipalFactory.cs#L79 + // since it's setting an authentication type which is not what we want. + // so we override this method to change it. + + // get the base ClaimsIdentity baseIdentity = await base.GenerateClaimsAsync(user); + // now create a new one with the correct authentication type + var id = new ClaimsIdentity( + AuthenticationType, + Options.ClaimsIdentity.UserNameClaimType, + Options.ClaimsIdentity.RoleClaimType); + + // and merge all others from the base implementation + id.MergeAllClaims(baseIdentity); + + // ensure our required claims are there baseIdentity.AddRequiredClaims( user.Id, user.UserName, @@ -51,21 +60,7 @@ namespace Umbraco.Cms.Core.Security // assigned which could be done in the OnExternalLogin callback baseIdentity.MergeClaimsFromBackOfficeIdentity(user); - return new ClaimsPrincipal(baseIdentity); - } - - /// - protected override async Task GenerateClaimsAsync(BackOfficeIdentityUser user) - { - // TODO: Have a look at the base implementation https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Extensions.Core/src/UserClaimsPrincipalFactory.cs#L79 - // since it's setting an authentication type that is probably not what we want. - // also, this is the method that we should be returning our UmbracoBackOfficeIdentity from , not the method above, - // the method above just returns a principal that wraps the identity and we dont use a custom principal, - // see https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Extensions.Core/src/UserClaimsPrincipalFactory.cs#L66 - - ClaimsIdentity identity = await base.GenerateClaimsAsync(user); - - return identity; + return id; } } } diff --git a/src/Umbraco.Infrastructure/Security/ClaimsIdentityExtensions.cs b/src/Umbraco.Infrastructure/Security/ClaimsIdentityExtensions.cs index 1a37376070..d4b61a934d 100644 --- a/src/Umbraco.Infrastructure/Security/ClaimsIdentityExtensions.cs +++ b/src/Umbraco.Infrastructure/Security/ClaimsIdentityExtensions.cs @@ -13,6 +13,15 @@ namespace Umbraco.Extensions // is re-issued and we don't want to merge old values of these. private static readonly string[] s_ignoredClaims = new[] { ClaimTypes.CookiePath, Constants.Security.SessionIdClaimType }; + public static void MergeAllClaims(this ClaimsIdentity destination, ClaimsIdentity source) + { + foreach (Claim claim in source.Claims + .Where(claim => !destination.HasClaim(claim.Type, claim.Value))) + { + destination.AddClaim(new Claim(claim.Type, claim.Value)); + } + } + public static void MergeClaimsFromBackOfficeIdentity(this ClaimsIdentity destination, ClaimsIdentity source) { foreach (Claim claim in source.Claims diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index c0b9a19ef1..7e36081e73 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -19,7 +19,7 @@ namespace Umbraco.Cms.Core.Security /// /// A custom user store that uses Umbraco member data /// - public class MemberUserStore : UserStoreBase, IdentityUserRole, IdentityUserLogin, IdentityUserToken, IdentityRoleClaim> + public class MemberUserStore : UserStoreBase, IdentityUserRole, IdentityUserLogin, IdentityUserToken, IdentityRoleClaim> { private const string genericIdentityErrorCode = "IdentityErrorUserStore"; private readonly IMemberService _memberService; @@ -562,7 +562,7 @@ namespace Umbraco.Cms.Core.Security } /// - protected override Task FindRoleAsync(string roleName, CancellationToken cancellationToken) + protected override Task FindRoleAsync(string roleName, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(roleName)) { @@ -572,10 +572,10 @@ namespace Umbraco.Cms.Core.Security IMemberGroup group = _memberService.GetAllRoles().SingleOrDefault(x => x.Name == roleName); if (group == null) { - return Task.FromResult((IdentityRole)null); + return Task.FromResult((UmbracoIdentityRole)null); } - return Task.FromResult(new IdentityRole(group.Name) + return Task.FromResult(new UmbracoIdentityRole(group.Name) { //TODO: what should the alias be? Id = group.Id.ToString() diff --git a/src/Umbraco.Infrastructure/Security/UmbracoIdentityRole.cs b/src/Umbraco.Infrastructure/Security/UmbracoIdentityRole.cs index 9d06dcd037..00c4038287 100644 --- a/src/Umbraco.Infrastructure/Security/UmbracoIdentityRole.cs +++ b/src/Umbraco.Infrastructure/Security/UmbracoIdentityRole.cs @@ -10,6 +10,14 @@ namespace Umbraco.Cms.Core.Models.Identity private string _id; private string _name; + public UmbracoIdentityRole(string roleName) : base(roleName) + { + } + + public UmbracoIdentityRole() + { + } + public event PropertyChangedEventHandler PropertyChanged { add diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs index 86f96c7d71..bdcc5ec75b 100644 --- a/src/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs @@ -1,7 +1,11 @@ +using System; +using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -15,26 +19,45 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Security [TestFixture] public class MemberSignInManagerTests { - private Mock> _mockLogger; - private readonly Mock> _memberManager = MockUserManager(); - private readonly Mock _mockIpResolver = new Mock(); + private Mock>> _mockLogger; + private readonly Mock> _memberManager = MockUserManager(); + + public MemberClaimsPrincipalFactory CreateClaimsFactory(UserManager userMgr) + => new MemberClaimsPrincipalFactory(userMgr, Options.Create(new MemberIdentityOptions())); public MemberSignInManager CreateSut() { - _mockLogger = new Mock>(); + // This all needs to be setup because internally aspnet resolves a bunch + // of services from the HttpContext.RequestServices. + var serviceProviderFactory = new DefaultServiceProviderFactory(); + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddAuthentication() + .AddCookie(IdentityConstants.ApplicationScheme); + IServiceProvider serviceProvider = serviceProviderFactory.CreateServiceProvider(serviceCollection); + var httpContextFactory = new DefaultHttpContextFactory(serviceProvider); + IFeatureCollection features = new DefaultHttpContext().Features; + features.Set(new HttpConnectionFeature + { + LocalIpAddress = IPAddress.Parse("127.0.0.1") + }); + HttpContext httpContext = httpContextFactory.Create(features); + + _mockLogger = new Mock>>(); return new MemberSignInManager( _memberManager.Object, - Mock.Of(), - Mock.Of>(), + Mock.Of(x => x.HttpContext == httpContext), + CreateClaimsFactory(_memberManager.Object), Mock.Of>(), - Mock.Of>>(), + _mockLogger.Object, Mock.Of(), Mock.Of>()); } - private static Mock> MockUserManager() where TUser : class + private static Mock> MockUserManager() { - var store = new Mock>(); - var mgr = new Mock>(store.Object, null, null, null, null, null, null, null, null); + var store = new Mock>(); + var mgr = new Mock>(store.Object, null, null, null, null, null, null, null, null); return mgr; } @@ -42,8 +65,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Security public async Task WhenPasswordSignInAsyncIsCalled_AndEverythingIsSetup_ThenASignInResultSucceededShouldBeReturnedAsync() { //arrange - var userId = "bo8w3d32q9b98"; - _memberManager.Setup(x => x.GetUserIdAsync(It.IsAny())).ReturnsAsync(userId); + var userId = "bo8w3d32q9b98"; MemberSignInManager sut = CreateSut(); var fakeUser = new MemberIdentityUser(777) { @@ -52,6 +74,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Security var password = "testPassword"; var lockoutOnFailure = false; var isPersistent = true; + + _memberManager.Setup(x => x.GetUserIdAsync(It.IsAny())).ReturnsAsync(userId); + _memberManager.Setup(x => x.GetUserNameAsync(It.IsAny())).ReturnsAsync(fakeUser.UserName); _memberManager.Setup(x => x.FindByNameAsync(It.IsAny())).ReturnsAsync(fakeUser); _memberManager.Setup(x => x.CheckPasswordAsync(fakeUser, password)).ReturnsAsync(true); _memberManager.Setup(x => x.IsEmailConfirmedAsync(fakeUser)).ReturnsAsync(true); @@ -76,14 +101,12 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Security var password = "testPassword"; var lockoutOnFailure = false; var isPersistent = true; - _mockIpResolver.Setup(x => x.GetCurrentRequestIpAddress()).Returns("127.0.0.1"); //act SignInResult actual = await sut.PasswordSignInAsync(fakeUser, password, isPersistent, lockoutOnFailure); //assert Assert.IsFalse(actual.Succeeded); - //_mockLogger.Verify(x => x.LogInformation("Login attempt failed for username TestUser from IP address 127.0.0.1", null)); } } } diff --git a/src/Umbraco.Web.Common/DependencyInjection/ServiceCollectionExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/ServiceCollectionExtensions.cs index 5182db4e20..b970f3e551 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/ServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ using SixLabors.ImageSharp.Web.DependencyInjection; using SixLabors.ImageSharp.Web.Processors; using SixLabors.ImageSharp.Web.Providers; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.Identity; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Web.Common.Security; @@ -67,10 +68,11 @@ namespace Umbraco.Extensions services.BuildMembersIdentity() .AddDefaultTokenProviders() .AddMemberManager() + .AddClaimsPrincipalFactory() .AddUserStore() .AddRoleStore() - .AddRoleValidator>() - .AddRoleManager>(); + .AddRoleValidator>() + .AddRoleManager>(); private static MemberIdentityBuilder BuildMembersIdentity(this IServiceCollection services) { @@ -78,7 +80,7 @@ namespace Umbraco.Extensions services.TryAddScoped, UserValidator>(); services.TryAddScoped, PasswordValidator>(); services.TryAddScoped, PasswordHasher>(); - return new MemberIdentityBuilder(typeof(IdentityRole), services); + return new MemberIdentityBuilder(typeof(UmbracoIdentityRole), services); } private static void RemoveIntParamenterIfValueGreatherThen(IDictionary commands, string parameter, int maxValue) diff --git a/src/Umbraco.Web.Common/Security/MemberClaimsPrincipalFactory.cs b/src/Umbraco.Web.Common/Security/MemberClaimsPrincipalFactory.cs new file mode 100644 index 0000000000..fe9a0eadd4 --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberClaimsPrincipalFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Security; + + +namespace Umbraco.Cms.Web.Common.Security +{ + public class MemberClaimsPrincipalFactory : UserClaimsPrincipalFactory + { + public MemberClaimsPrincipalFactory(UserManager userManager, IOptions optionsAccessor) + : base(userManager, optionsAccessor) + { + } + } +} diff --git a/src/Umbraco.Web.Common/Security/MemberManager.cs b/src/Umbraco.Web.Common/Security/MemberManager.cs index 195312a41e..f3b80ba4bc 100644 --- a/src/Umbraco.Web.Common/Security/MemberManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberManager.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Web.Common.Security { + public class MemberManager : UmbracoUserManager, IMemberManager { public MemberManager( diff --git a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs index 392d20ebbe..eeec3c2899 100644 --- a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs @@ -38,27 +38,6 @@ namespace Umbraco.Cms.Web.Common.Security // use default scheme for members protected override string TwoFactorRememberMeAuthenticationType => IdentityConstants.TwoFactorRememberMeScheme; - /// - public override async Task PasswordSignInAsync(MemberIdentityUser user, string password, bool isPersistent, bool lockoutOnFailure) - { - // overridden to handle logging/events - SignInResult result = await base.PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); - return await HandleSignIn(user, user.UserName, result); - } - - /// - public override async Task PasswordSignInAsync(string userName, string password, bool isPersistent, bool lockoutOnFailure) - { - // overridden to handle logging/events - MemberIdentityUser user = await UserManager.FindByNameAsync(userName); - if (user == null) - { - return await HandleSignIn(null, userName, SignInResult.Failed); - } - - return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); - } - /// public override Task GetTwoFactorAuthenticationUserAsync() => throw new NotImplementedException("Two factor is not yet implemented for members"); diff --git a/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs b/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs new file mode 100644 index 0000000000..ea29098bef --- /dev/null +++ b/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs @@ -0,0 +1,453 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Models.Identity; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Common.Security +{ + /// + /// Abstract sign in manager implementation allowing modifying all defeault authentication schemes + /// + /// + public abstract class UmbracoSignInManager : SignInManager + where TUser : UmbracoIdentityUser + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + protected const string UmbracoSignInMgrLoginProviderKey = "LoginProvider"; + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + protected const string UmbracoSignInMgrXsrfKey = "XsrfId"; + + public UmbracoSignInManager(UserManager userManager, IHttpContextAccessor contextAccessor, IUserClaimsPrincipalFactory claimsFactory, IOptions optionsAccessor, ILogger> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation) : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + { + } + + protected abstract string AuthenticationType { get; } + protected abstract string ExternalAuthenticationType { get; } + protected abstract string TwoFactorAuthenticationType { get; } + protected abstract string TwoFactorRememberMeAuthenticationType { get; } + + /// + public override async Task PasswordSignInAsync(TUser user, string password, bool isPersistent, bool lockoutOnFailure) + { + // override to handle logging/events + var result = await base.PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); + return await HandleSignIn(user, user.UserName, result); + } + + /// + public override async Task GetExternalLoginInfoAsync(string expectedXsrf = null) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L422 + // to replace the auth scheme + + var auth = await Context.AuthenticateAsync(ExternalAuthenticationType); + var items = auth?.Properties?.Items; + if (auth?.Principal == null || items == null || !items.ContainsKey(UmbracoSignInMgrLoginProviderKey)) + { + return null; + } + + if (expectedXsrf != null) + { + if (!items.ContainsKey(UmbracoSignInMgrXsrfKey)) + { + return null; + } + var userId = items[UmbracoSignInMgrXsrfKey]; + if (userId != expectedXsrf) + { + return null; + } + } + + var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (providerKey == null || items[UmbracoSignInMgrLoginProviderKey] is not string provider) + { + return null; + } + + var providerDisplayName = (await GetExternalAuthenticationSchemesAsync()).FirstOrDefault(p => p.Name == provider)?.DisplayName ?? provider; + return new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName) + { + AuthenticationTokens = auth.Properties.GetTokens(), + AuthenticationProperties = auth.Properties + }; + } + + /// + public override async Task GetTwoFactorAuthenticationUserAsync() + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // replaced in order to use a custom auth type + + var info = await RetrieveTwoFactorInfoAsync(); + if (info == null) + { + return null; + } + return await UserManager.FindByIdAsync(info.UserId); + } + + /// + public override async Task PasswordSignInAsync(string userName, string password, bool isPersistent, bool lockoutOnFailure) + { + // override to handle logging/events + var user = await UserManager.FindByNameAsync(userName); + if (user == null) + { + return await HandleSignIn(null, userName, SignInResult.Failed); + } + + return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); + } + + /// + public override bool IsSignedIn(ClaimsPrincipal principal) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L126 + // replaced in order to use a custom auth type + + if (principal == null) + { + throw new ArgumentNullException(nameof(principal)); + } + return principal?.Identities != null && + principal.Identities.Any(i => i.AuthenticationType == AuthenticationType); + } + + /// + public override async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L552 + // replaced in order to use a custom auth type and to implement logging/events + + var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); + if (twoFactorInfo == null || twoFactorInfo.UserId == null) + { + return SignInResult.Failed; + } + var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId); + if (user == null) + { + return SignInResult.Failed; + } + + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + if (await UserManager.VerifyTwoFactorTokenAsync(user, provider, code)) + { + await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); + return await HandleSignIn(user, user?.UserName, 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 await HandleSignIn(user, user?.UserName, SignInResult.Failed); + } + + /// + public override async Task RefreshSignInAsync(TUser user) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L126 + // replaced in order to use a custom auth type + + var auth = await Context.AuthenticateAsync(AuthenticationType); + IList claims = Array.Empty(); + + var authenticationMethod = auth?.Principal?.FindFirst(ClaimTypes.AuthenticationMethod); + var amr = auth?.Principal?.FindFirst("amr"); + + if (authenticationMethod != null || amr != null) + { + claims = new List(); + if (authenticationMethod != null) + { + claims.Add(authenticationMethod); + } + if (amr != null) + { + claims.Add(amr); + } + } + + await SignInWithClaimsAsync(user, auth?.Properties, claims); + } + + /// + public override async Task SignInWithClaimsAsync(TUser user, AuthenticationProperties authenticationProperties, IEnumerable additionalClaims) + { + // override to replace IdentityConstants.ApplicationScheme with custom AuthenticationType + // code taken from aspnetcore: https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // we also override to set the current HttpContext principal since this isn't done by default + + var userPrincipal = await CreateUserPrincipalAsync(user); + foreach (var claim in additionalClaims) + { + userPrincipal.Identities.First().AddClaim(claim); + } + + // FYI (just for informational purposes): + // This calls an ext method will eventually reaches `IAuthenticationService.SignInAsync` + // which then resolves the `IAuthenticationSignInHandler` for the current scheme + // by calling `IAuthenticationHandlerProvider.GetHandlerAsync(context, scheme);` + // which then calls `IAuthenticationSignInHandler.SignInAsync` = CookieAuthenticationHandler.HandleSignInAsync + + // Also note, that when the CookieAuthenticationHandler sign in is successful we handle that event within our + // own ConfigureUmbracoBackOfficeCookieOptions which assigns the current HttpContext.User to the IPrincipal created + + // Also note, this method gets called when performing 2FA logins + + await Context.SignInAsync( + AuthenticationType, + userPrincipal, + authenticationProperties ?? new AuthenticationProperties()); + } + + /// + public override async Task SignOutAsync() + { + // override to replace IdentityConstants.ApplicationScheme with custom auth types + // code taken from aspnetcore: https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + + await Context.SignOutAsync(AuthenticationType); + await Context.SignOutAsync(ExternalAuthenticationType); + await Context.SignOutAsync(TwoFactorAuthenticationType); + } + + /// + public override async Task IsTwoFactorClientRememberedAsync(TUser user) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L422 + // to replace the auth scheme + + var userId = await UserManager.GetUserIdAsync(user); + var result = await Context.AuthenticateAsync(TwoFactorRememberMeAuthenticationType); + return (result?.Principal != null && result.Principal.FindFirstValue(ClaimTypes.Name) == userId); + } + + /// + public override async Task RememberTwoFactorClientAsync(TUser user) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L422 + // to replace the auth scheme + + var principal = await StoreRememberClient(user); + await Context.SignInAsync(TwoFactorRememberMeAuthenticationType, + principal, + new AuthenticationProperties { IsPersistent = true }); + } + + /// + public override async Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L422 + // to replace the auth scheme + + var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); + if (twoFactorInfo == null || twoFactorInfo.UserId == null) + { + return SignInResult.Failed; + } + var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId); + if (user == null) + { + return SignInResult.Failed; + } + + var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode); + if (result.Succeeded) + { + await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent: false, rememberClient: false); + return SignInResult.Success; + } + + // We don't protect against brute force attacks since codes are expected to be random. + return SignInResult.Failed; + } + + /// + public override Task ForgetTwoFactorClientAsync() + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L422 + // to replace the auth scheme + + return Context.SignOutAsync(TwoFactorRememberMeAuthenticationType); + } + + /// + /// Called on any login attempt to update the AccessFailedCount and to raise events + /// + /// + /// + /// + /// + protected virtual async Task HandleSignIn(TUser user, string username, SignInResult result) + { + // TODO: Here I believe we can do all (or most) of the usermanager event raising so that it is not in the AuthenticationController + + if (username.IsNullOrWhiteSpace()) + { + username = "UNKNOWN"; // could happen in 2fa or something else weird + } + + if (result.Succeeded) + { + //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); + + Logger.LogInformation("User: {UserName} logged in from IP address {IpAddress}", username, Context.Connection.RemoteIpAddress); + } + else if (result.IsLockedOut) + { + Logger.LogInformation("Login attempt failed for username {UserName} from IP address {IpAddress}, the user is locked", username, Context.Connection.RemoteIpAddress); + } + else if (result.RequiresTwoFactor) + { + Logger.LogInformation("Login attempt requires verification for username {UserName} from IP address {IpAddress}", username, Context.Connection.RemoteIpAddress); + } + else if (!result.Succeeded || result.IsNotAllowed) + { + Logger.LogInformation("Login attempt failed for username {UserName} from IP address {IpAddress}", username, Context.Connection.RemoteIpAddress); + } + else + { + throw new ArgumentOutOfRangeException(); + } + + return result; + } + + /// + protected override async Task SignInOrTwoFactorAsync(TUser user, bool isPersistent, string loginProvider = null, bool bypassTwoFactor = false) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // to replace custom auth types + + if (!bypassTwoFactor && await IsTfaEnabled(user)) + { + if (!await IsTwoFactorClientRememberedAsync(user)) + { + // Store the userId for use after two factor check + var userId = await UserManager.GetUserIdAsync(user); + await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, StoreTwoFactorInfo(userId, loginProvider)); + return SignInResult.TwoFactorRequired; + } + } + // Cleanup external cookie + if (loginProvider != null) + { + await Context.SignOutAsync(ExternalAuthenticationType); + } + if (loginProvider == null) + { + await SignInWithClaimsAsync(user, isPersistent, new Claim[] { new Claim("amr", "pwd") }); + } + else + { + await SignInAsync(user, isPersistent, loginProvider); + } + return SignInResult.Success; + } + + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L782 + // since it's not public + private async Task IsTfaEnabled(TUser user) + => UserManager.SupportsUserTwoFactor && + await UserManager.GetTwoFactorEnabledAsync(user) && + (await UserManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; + + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L743 + // to replace custom auth types + private ClaimsPrincipal StoreTwoFactorInfo(string userId, string loginProvider) + { + var identity = new ClaimsIdentity(TwoFactorAuthenticationType); + identity.AddClaim(new Claim(ClaimTypes.Name, userId)); + if (loginProvider != null) + { + identity.AddClaim(new Claim(ClaimTypes.AuthenticationMethod, loginProvider)); + } + return new ClaimsPrincipal(identity); + } + + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // copy is required in order to use custom auth types + private async Task StoreRememberClient(TUser user) + { + var userId = await UserManager.GetUserIdAsync(user); + var rememberBrowserIdentity = new ClaimsIdentity(TwoFactorRememberMeAuthenticationType); + rememberBrowserIdentity.AddClaim(new Claim(ClaimTypes.Name, userId)); + if (UserManager.SupportsUserSecurityStamp) + { + var stamp = await UserManager.GetSecurityStampAsync(user); + rememberBrowserIdentity.AddClaim(new Claim(Options.ClaimsIdentity.SecurityStampClaimType, stamp)); + } + return new ClaimsPrincipal(rememberBrowserIdentity); + } + + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // copy is required in order to use a custom auth type + private async Task RetrieveTwoFactorInfoAsync() + { + var result = await Context.AuthenticateAsync(TwoFactorAuthenticationType); + if (result?.Principal != null) + { + return new TwoFactorAuthenticationInfo + { + UserId = result.Principal.FindFirstValue(ClaimTypes.Name), + LoginProvider = result.Principal.FindFirstValue(ClaimTypes.AuthenticationMethod) + }; + } + return null; + } + + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // copy is required in order to use custom auth types + private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAuthenticationInfo twoFactorInfo, bool isPersistent, bool rememberClient) + { + // When token is verified correctly, clear the access failed count used for lockout + await ResetLockout(user); + + var claims = new List + { + new Claim("amr", "mfa") + }; + + // Cleanup external cookie + if (twoFactorInfo.LoginProvider != null) + { + claims.Add(new Claim(ClaimTypes.AuthenticationMethod, twoFactorInfo.LoginProvider)); + await Context.SignOutAsync(ExternalAuthenticationType); + } + // Cleanup two factor user id cookie + await Context.SignOutAsync(TwoFactorAuthenticationType); + if (rememberClient) + { + await RememberTwoFactorClientAsync(user); + } + await SignInWithClaimsAsync(user, isPersistent, claims); + } + + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L891 + private class TwoFactorAuthenticationInfo + { + public string UserId { get; set; } + public string LoginProvider { get; set; } + } + } +}