diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs index 83e94c1e30..c905876f51 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs @@ -403,55 +403,182 @@ namespace Umbraco.Web.BackOffice.Controllers { if (loginInfo == null) throw new ArgumentNullException(nameof(loginInfo)); if (response == null) throw new ArgumentNullException(nameof(response)); + ExternalSignInAutoLinkOptions autoLinkOptions = null; - // Sign in the user with this external login provider (which auto links, etc...) - var result = await _signInManager.ExternalLoginSignInAsync(loginInfo, isPersistent: false); + var authType = (await _signInManager.GetExternalAuthenticationSchemesAsync()) + .FirstOrDefault(x => x.Name == loginInfo.LoginProvider); - var errors = new List(); - - if (result == Microsoft.AspNetCore.Identity.SignInResult.Success) + if (authType == null) { - + _logger.LogWarning("Could not find external authentication provider registered: {LoginProvider}", loginInfo.LoginProvider); } - else if (result == Microsoft.AspNetCore.Identity.SignInResult.LockedOut) + else { - // TODO: We've never actually dealt with this before - } - else if (result == Microsoft.AspNetCore.Identity.SignInResult.TwoFactorRequired) - { - // TODO: We've never actually dealt with this before - } - else if (result == Microsoft.AspNetCore.Identity.SignInResult.NotAllowed) - { - // TODO: We've never actually dealt with this before - } - else if (result == Microsoft.AspNetCore.Identity.SignInResult.Failed) - { - // Failed only occurs when the user does not exist - errors.Add("The requested provider (" + loginInfo.LoginProvider + ") has not been linked to an account, the provider must be linked from the back office."); - } - else if (result == AutoLinkSignInResult.FailedNotLinked) - { - errors.Add("The requested provider (" + loginInfo.LoginProvider + ") has not been linked to an account, the provider must be linked from the back office."); - } - else if (result == AutoLinkSignInResult.FailedNoEmail) - { - errors.Add($"The requested provider ({loginInfo.LoginProvider}) has not provided the email claim {ClaimTypes.Email}, the account cannot be linked."); - } - else if (result is AutoLinkSignInResult autoLinkSignInResult && autoLinkSignInResult.Errors.Count > 0) - { - errors.AddRange(autoLinkSignInResult.Errors); + autoLinkOptions = _externalLogins.Get(authType.Name)?.Options?.AutoLinkOptions; } - if (errors.Count > 0) + // Sign in the user with this external login provider if the user already has a login + + var user = await _userManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); + if (user != null) + { + var shouldSignIn = true; + if (autoLinkOptions != null && autoLinkOptions.OnExternalLogin != null) + { + shouldSignIn = autoLinkOptions.OnExternalLogin(user, loginInfo); + if (shouldSignIn == false) + { + _logger.LogWarning("The AutoLinkOptions of the external authentication provider '{LoginProvider}' have refused the login based on the OnExternalLogin method. Affected user id: '{UserId}'", loginInfo.LoginProvider, user.Id); + } + } + + if (shouldSignIn) + { + //sign in + await _signInManager.SignInAsync(user, false); + } + } + else + { + if (await AutoLinkAndSignInExternalAccount(loginInfo, autoLinkOptions) == false) + { + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.LoginProvider, + new[] { "The requested provider (" + loginInfo.LoginProvider + ") has not been linked to an account, the provider must be linked from the back office." })); + } + + //Remove the cookie otherwise this message will keep appearing + Response.Cookies.Delete(Constants.Security.BackOfficeExternalCookieName); + } + + return response(); + } + + private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo, ExternalSignInAutoLinkOptions autoLinkOptions) + { + if (autoLinkOptions == null) + return false; + + if (autoLinkOptions.AutoLinkExternalAccount == false) + { + return false; + } + + var email = loginInfo.Principal.FindFirstValue(ClaimTypes.Email); + + //we are allowing auto-linking/creating of local accounts + if (email.IsNullOrWhiteSpace()) { ViewData.SetExternalSignInProviderErrors( new BackOfficeExternalLoginProviderErrors( loginInfo.LoginProvider, - errors)); + new[] { $"The requested provider ({loginInfo.LoginProvider}) has not provided the email claim {ClaimTypes.Email}, the account cannot be linked." })); + } + else + { + //Now we need to perform the auto-link, so first we need to lookup/create a user with the email address + var autoLinkUser = await _userManager.FindByEmailAsync(email); + if (autoLinkUser != null) + { + try + { + //call the callback if one is assigned + autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.LoginProvider, + new[] { "Could not link login provider " + loginInfo.LoginProvider + ". " + ex.Message })); + return true; + } + + await LinkUser(autoLinkUser, loginInfo); + } + else + { + var name = loginInfo.Principal?.Identity?.Name; + if (name.IsNullOrWhiteSpace()) throw new InvalidOperationException("The Name value cannot be null"); + + autoLinkUser = BackOfficeIdentityUser.CreateNew(_globalSettings, email, email, autoLinkOptions.GetUserAutoLinkCulture(_globalSettings), name); + + foreach (var userGroup in autoLinkOptions.DefaultUserGroups) + { + autoLinkUser.AddRole(userGroup); + } + + //call the callback if one is assigned + try + { + autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.LoginProvider, + new[] { "Could not link login provider " + loginInfo.LoginProvider + ". " + ex.Message })); + return true; + } + + var userCreationResult = await _userManager.CreateAsync(autoLinkUser); + + if (userCreationResult.Succeeded == false) + { + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.LoginProvider, + userCreationResult.Errors.Select(x => x.Description).ToList())); + } + else + { + await LinkUser(autoLinkUser, loginInfo); + } + } + } + return true; + } + + private async Task LinkUser(BackOfficeIdentityUser autoLinkUser, ExternalLoginInfo loginInfo) + { + var existingLogins = await _userManager.GetLoginsAsync(autoLinkUser); + var exists = existingLogins.FirstOrDefault(x => x.LoginProvider == loginInfo.LoginProvider && x.ProviderKey == loginInfo.ProviderKey); + + // if it already exists (perhaps it was added in the AutoLink callbak) then we just continue + if (exists != null) + { + //sign in + await _signInManager.SignInAsync(autoLinkUser, isPersistent: false); + return; } - return response(); + var linkResult = await _userManager.AddLoginAsync(autoLinkUser, loginInfo); + if (linkResult.Succeeded) + { + //we're good! sign in + await _signInManager.SignInAsync(autoLinkUser, isPersistent: false); + return; + } + + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.LoginProvider, + linkResult.Errors.Select(x => x.Description).ToList())); + + //If this fails, we should really delete the user since it will be in an inconsistent state! + var deleteResult = await _userManager.DeleteAsync(autoLinkUser); + if (!deleteResult.Succeeded) + { + //DOH! ... this isn't good, combine all errors to be shown + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.LoginProvider, + linkResult.Errors.Concat(deleteResult.Errors).Select(x => x.Description).ToList())); + } } private IActionResult RedirectToLocal(string returnUrl) diff --git a/src/Umbraco.Web.BackOffice/Security/AutoLinkSignInResult.cs b/src/Umbraco.Web.BackOffice/Security/AutoLinkSignInResult.cs deleted file mode 100644 index f0d79e5ec6..0000000000 --- a/src/Umbraco.Web.BackOffice/Security/AutoLinkSignInResult.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; - -namespace Umbraco.Web.Common.Security -{ - public class AutoLinkSignInResult : SignInResult - { - public static AutoLinkSignInResult FailedNotLinked = new() - { - Succeeded = false - }; - - public static AutoLinkSignInResult FailedNoEmail = new() - { - Succeeded = false - }; - - public static AutoLinkSignInResult FailedException(string error) => new(new[] { error }) - { - Succeeded = false - }; - - public static AutoLinkSignInResult FailedCreatingUser(IReadOnlyCollection errors) => new(errors) - { - Succeeded = false - }; - - public static AutoLinkSignInResult FailedLinkingUser(IReadOnlyCollection errors) => new(errors) - { - Succeeded = false - }; - - public AutoLinkSignInResult(IReadOnlyCollection errors) - { - Errors = errors ?? throw new ArgumentNullException(nameof(errors)); - } - - public AutoLinkSignInResult() - { - } - - public IReadOnlyCollection Errors { get; } = Array.Empty(); - } -} diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviderOptions.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviderOptions.cs index b6c1c7f2d2..a3ce87e404 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviderOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviderOptions.cs @@ -11,7 +11,7 @@ namespace Umbraco.Web.BackOffice.Security public class BackOfficeExternalLoginProviderOptions { public BackOfficeExternalLoginProviderOptions( - string buttonStyle, string icon, + string buttonStyle, string icon, string callbackPath, ExternalSignInAutoLinkOptions autoLinkOptions = null, bool denyLocalLogin = false, bool autoRedirectLoginToExternalProvider = false, @@ -19,6 +19,7 @@ namespace Umbraco.Web.BackOffice.Security { ButtonStyle = buttonStyle; Icon = icon; + CallbackPath = callbackPath; AutoLinkOptions = autoLinkOptions ?? new ExternalSignInAutoLinkOptions(); DenyLocalLogin = denyLocalLogin; AutoRedirectLoginToExternalProvider = autoRedirectLoginToExternalProvider; @@ -27,6 +28,7 @@ namespace Umbraco.Web.BackOffice.Security public string ButtonStyle { get; } public string Icon { get; } + public string CallbackPath { get; } /// /// Options used to control how users can be auto-linked/created/updated based on the external login provider diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs index df838856f1..6f34a85c79 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs @@ -10,9 +10,7 @@ using System.Security.Claims; using System.Threading.Tasks; using Umbraco.Core; using Umbraco.Core.BackOffice; -using Umbraco.Core.Configuration.Models; using Umbraco.Extensions; -using Umbraco.Web.BackOffice.Security; namespace Umbraco.Web.Common.Security { @@ -29,25 +27,18 @@ namespace Umbraco.Web.Common.Security private const string XsrfKey = "XsrfId"; private BackOfficeUserManager _userManager; - private readonly IBackOfficeExternalLoginProviders _externalLogins; - private readonly GlobalSettings _globalSettings; - public BackOfficeSignInManager( BackOfficeUserManager userManager, IHttpContextAccessor contextAccessor, - IBackOfficeExternalLoginProviders externalLogins, IUserClaimsPrincipalFactory claimsFactory, IOptions optionsAccessor, - IOptions globalSettings, ILogger> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation) : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) { _userManager = userManager; - _externalLogins = externalLogins; - _globalSettings = globalSettings.Value; } // TODO: Need to migrate more from Umbraco.Web.Security.BackOfficeSignInManager @@ -309,51 +300,7 @@ namespace Umbraco.Web.Common.Security }; } - /// - /// Custom ExternalLoginSignInAsync overload for handling external sign in with auto-linking - /// - /// - /// - /// - /// - /// - public async Task ExternalLoginSignInAsync(ExternalLoginInfo loginInfo, bool isPersistent, bool bypassTwoFactor = false) - { - // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs - // to be able to deal with auto-linking and reduce duplicate lookups - - var autoLinkOptions = _externalLogins.Get(loginInfo.LoginProvider)?.Options?.AutoLinkOptions; - var user = await UserManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); - if (user == null) - { - // user doesn't exist so see if we can auto link - return await AutoLinkAndSignInExternalAccount(loginInfo, autoLinkOptions); - } - - if (autoLinkOptions != null && autoLinkOptions.OnExternalLogin != null) - { - var shouldSignIn = autoLinkOptions.OnExternalLogin(user, loginInfo); - if (shouldSignIn == false) - { - Logger.LogWarning("The AutoLinkOptions of the external authentication provider '{LoginProvider}' have refused the login based on the OnExternalLogin method. Affected user id: '{UserId}'", loginInfo.LoginProvider, user.Id); - } - } - - var error = await PreSignInCheck(user); - if (error != null) - { - return error; - } - return await SignInOrTwoFactorAsync(user, isPersistent, loginInfo.LoginProvider, bypassTwoFactor); - } - - public override Task> GetExternalAuthenticationSchemesAsync() - { - // TODO: We can filter these so that they only include the back office ones. - // That can be done by either checking the scheme (maybe) or comparing it to what we have registered in the collection of BackOfficeExternalLoginProvider - return base.GetExternalAuthenticationSchemesAsync(); - } - + /// protected override async Task SignInOrTwoFactorAsync(BackOfficeIdentityUser user, bool isPersistent, string loginProvider = null, bool bypassTwoFactor = false) { @@ -524,117 +471,5 @@ namespace Umbraco.Web.Common.Security public string UserId { get; set; } public string LoginProvider { get; set; } } - - - /// - /// Used for auto linking/creating user accounts for external logins - /// - /// - /// - /// - private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo, ExternalSignInAutoLinkOptions autoLinkOptions) - { - // If there are no autolink options then the attempt is failed (user does not exist) - if (autoLinkOptions == null || !autoLinkOptions.AutoLinkExternalAccount) - { - return SignInResult.Failed; - } - - var email = loginInfo.Principal.FindFirstValue(ClaimTypes.Email); - - //we are allowing auto-linking/creating of local accounts - if (email.IsNullOrWhiteSpace()) - { - return AutoLinkSignInResult.FailedNoEmail; - } - else - { - //Now we need to perform the auto-link, so first we need to lookup/create a user with the email address - var autoLinkUser = await UserManager.FindByEmailAsync(email); - if (autoLinkUser != null) - { - try - { - //call the callback if one is assigned - autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); - } - catch (Exception ex) - { - Logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); - return AutoLinkSignInResult.FailedException(ex.Message); - } - - return await LinkUser(autoLinkUser, loginInfo); - } - else - { - var name = loginInfo.Principal?.Identity?.Name; - if (name.IsNullOrWhiteSpace()) throw new InvalidOperationException("The Name value cannot be null"); - - autoLinkUser = BackOfficeIdentityUser.CreateNew(_globalSettings, email, email, autoLinkOptions.GetUserAutoLinkCulture(_globalSettings), name); - - foreach (var userGroup in autoLinkOptions.DefaultUserGroups) - { - autoLinkUser.AddRole(userGroup); - } - - //call the callback if one is assigned - try - { - autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); - } - catch (Exception ex) - { - Logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); - return AutoLinkSignInResult.FailedException(ex.Message); - } - - var userCreationResult = await _userManager.CreateAsync(autoLinkUser); - - if (!userCreationResult.Succeeded) - { - return AutoLinkSignInResult.FailedCreatingUser(userCreationResult.Errors.Select(x => x.Description).ToList()); - } - else - { - return await LinkUser(autoLinkUser, loginInfo); - } - } - } - } - - private async Task LinkUser(BackOfficeIdentityUser autoLinkUser, ExternalLoginInfo loginInfo) - { - var existingLogins = await _userManager.GetLoginsAsync(autoLinkUser); - var exists = existingLogins.FirstOrDefault(x => x.LoginProvider == loginInfo.LoginProvider && x.ProviderKey == loginInfo.ProviderKey); - - // if it already exists (perhaps it was added in the AutoLink callbak) then we just continue - if (exists != null) - { - //sign in - return await SignInOrTwoFactorAsync(autoLinkUser, isPersistent: false, loginInfo.LoginProvider); - } - - var linkResult = await _userManager.AddLoginAsync(autoLinkUser, loginInfo); - if (linkResult.Succeeded) - { - //we're good! sign in - return await SignInOrTwoFactorAsync(autoLinkUser, isPersistent: false, loginInfo.LoginProvider); - } - - //If this fails, we should really delete the user since it will be in an inconsistent state! - var deleteResult = await _userManager.DeleteAsync(autoLinkUser); - if (deleteResult.Succeeded) - { - var errors = linkResult.Errors.Select(x => x.Description).ToList(); - return AutoLinkSignInResult.FailedLinkingUser(errors); - } - else - { - //DOH! ... this isn't good, combine all errors to be shown - var errors = linkResult.Errors.Concat(deleteResult.Errors).Select(x => x.Description).ToList(); - return AutoLinkSignInResult.FailedLinkingUser(errors); - } - } } } diff --git a/src/Umbraco.Web.Common/Filters/UmbracoBackOfficeAuthorizeFilter.cs b/src/Umbraco.Web.Common/Filters/UmbracoBackOfficeAuthorizeFilter.cs index 8fad886f27..0c30b25ced 100644 --- a/src/Umbraco.Web.Common/Filters/UmbracoBackOfficeAuthorizeFilter.cs +++ b/src/Umbraco.Web.Common/Filters/UmbracoBackOfficeAuthorizeFilter.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Routing; using System; @@ -29,7 +28,6 @@ namespace Umbraco.Web.Common.Filters private readonly LinkGenerator _linkGenerator; private readonly bool _redirectToUmbracoLogin; private string _redirectUrl; - private UmbracoBackOfficeAuthorizeFilter( IHostingEnvironment hostingEnvironment,