From 742307dc325ac1e565655ce1d1f7cca2997d300b Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 14 May 2024 15:55:32 +0200 Subject: [PATCH] [v14] backoffice user login providers endpoint (#16141) * Added endpoint and backing service for backoffice login providers and the status per user. * Improve link login redirect forming and error handling * Add responseModel and mapping instead of returning core model * Moved unlink endpoint logic into a service * Refactored ExternalLinkLoginCallback logic into BackofficeExternalLoginService method * typo and minor code style improvements * async method name alignment * Add BackOfficeExternalLoginService tests * Remove helper method that makes less sense that thought. * Minor formatting, clean-up and conventions * Replaced cookie authentication in link-login with a short lived secret Applied PR feedback * Update openapi * Changed link login to a form endpoint * fix broken comment link * Do not store claimsprinciple in secret + comments * update redirect paths --------- Co-authored-by: Sven Geusens Co-authored-by: kjac Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> --- .../Security/BackOfficeController.cs | 204 +++++++++-------- ...rnalLoginProvidersCurrentUserController.cs | 45 ++++ .../User/UserOrCurrentUserControllerBase.cs | 12 + .../UmbracoBuilder.BackOfficeIdentity.cs | 3 + .../Users/UsersViewModelsMapDefinition.cs | 15 +- .../Models/LoginProviderUserLink.cs | 10 + src/Umbraco.Cms.Api.Management/OpenApi.json | 55 +++++ .../BackOfficeExternalLoginService.cs | 212 ++++++++++++++++++ .../IBackOfficeExternalLoginService.cs | 24 ++ .../Security/LinkLoginRequestModel.cs | 8 + .../UserExternalLoginProviderResponseModel.cs | 10 + .../Models/UserExternalLoginProviderModel.cs | 17 ++ .../ExternalLoginOperationStatus.cs | 18 ++ .../Helpers/OAuthOptionsHelper.cs | 6 +- ...ceTests.ExternalLoginStatusForUserAsync.cs | 102 +++++++++ ...ernalLoginServiceTests.UnLinkLoginAsync.cs | 146 ++++++++++++ .../BackOfficeExternalLoginServiceTests.cs | 66 ++++++ .../Umbraco.Tests.UnitTests.csproj | 6 + 18 files changed, 864 insertions(+), 95 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/User/Current/ListExternalLoginProvidersCurrentUserController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Models/LoginProviderUserLink.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/IBackOfficeExternalLoginService.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Security/LinkLoginRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/User/Current/UserExternalLoginProviderResponseModel.cs create mode 100644 src/Umbraco.Core/Models/UserExternalLoginProviderModel.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/ExternalLoginOperationStatus.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.ExternalLoginStatusForUserAsync.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.UnLinkLoginAsync.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs index 4c42ea6b19..fd6d9116c5 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs @@ -13,12 +13,14 @@ using OpenIddict.Server.AspNetCore; using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Routing; using Umbraco.Cms.Api.Management.Security; +using Umbraco.Cms.Api.Management.Services; using Umbraco.Cms.Api.Management.ViewModels.Security; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; @@ -39,7 +41,11 @@ public class BackOfficeController : SecurityControllerBase private readonly ILogger _logger; private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions; private readonly IUserTwoFactorLoginService _userTwoFactorLoginService; - private readonly IBackOfficeExternalLoginProviders _backOfficeExternalLoginProviders; + private readonly IBackOfficeExternalLoginService _externalLoginService; + + private const string RedirectFlowParameter = "flow"; + private const string RedirectStatusParameter = "status"; + private const string RedirectErrorCodeParameter = "errorCode"; public BackOfficeController( IHttpContextAccessor httpContextAccessor, @@ -49,7 +55,7 @@ public class BackOfficeController : SecurityControllerBase ILogger logger, IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions, IUserTwoFactorLoginService userTwoFactorLoginService, - IBackOfficeExternalLoginProviders backOfficeExternalLoginProviders) + IBackOfficeExternalLoginService externalLoginService) { _httpContextAccessor = httpContextAccessor; _backOfficeSignInManager = backOfficeSignInManager; @@ -58,7 +64,7 @@ public class BackOfficeController : SecurityControllerBase _logger = logger; _backOfficeTwoFactorOptions = backOfficeTwoFactorOptions; _userTwoFactorLoginService = userTwoFactorLoginService; - _backOfficeExternalLoginProviders = backOfficeExternalLoginProviders; + _externalLoginService = externalLoginService; } [HttpPost("login")] @@ -76,6 +82,7 @@ public class BackOfficeController : SecurityControllerBase .WithDetail("The operation is not allowed on the user") .Build()); } + if (result.IsLockedOut) { return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() @@ -83,6 +90,7 @@ public class BackOfficeController : SecurityControllerBase .WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.") .Build()); } + if(result.RequiresTwoFactor) { string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username); @@ -189,28 +197,70 @@ public class BackOfficeController : SecurityControllerBase return SignOut(Constants.Security.BackOfficeAuthenticationType, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + // Creates and retains a short lived secret to use in the link-login + // endpoint because we can not protect that method with a bearer token for reasons explained there + [HttpGet("link-login-key")] + [MapToApiVersion("1.0")] + public async Task LinkLoginKey(string provider) + { + Attempt generateSecretAttempt = await _externalLoginService.GenerateLoginProviderSecretAsync(User, provider); + return generateSecretAttempt.Success + ? Ok(generateSecretAttempt.Result) + : generateSecretAttempt.Status is ExternalLoginOperationStatus.AuthenticationSchemeNotFound + ? StatusCode(StatusCodes.Status400BadRequest, new ProblemDetailsBuilder() + .WithTitle("Invalid provider") + .WithDetail($"No provider with scheme name '{provider}' is configured") + .Build()) + : Unauthorized(); + } + /// /// Called when a user links an external login provider in the back office /// /// /// + // This method is marked as AllowAnonymous and protected with a secret (linkKey) inside the model for the following reasons + // - when a js client uses the fetch api (or old ajax requests) they can send a bearer token + // but since this method returns a redirect (after middleware intervenes) to another domain + // and the redirect can not be intercepted, a cors error is thrown on the client + // - if we switch this method to a form post or a plain get, cors is not an issue, but the client + // can't set a bearer token header. + // we are forcing form usage here for the whole model so the secret does not end up in url logs. [HttpPost("link-login")] + [AllowAnonymous] [MapToApiVersion("1.0")] - public IActionResult LinkLogin(string provider) + public async Task LinkLogin([FromForm] LinkLoginRequestModel requestModel) { + Attempt claimsPrincipleAttempt = await _externalLoginService.ClaimsPrincipleFromLoginProviderLinkKeyAsync(requestModel.Provider, requestModel.LinkKey); + + if (claimsPrincipleAttempt.Success == false) + { + return Redirect(_securitySettings.Value.BackOfficeHost + "/" + _securitySettings.Value.AuthorizeCallbackErrorPathName.TrimStart('/').AppendQueryStringToUrl( + $"{RedirectFlowParameter}=link-login", + $"{RedirectStatusParameter}=unauthorized")); + } + + BackOfficeIdentityUser? user = await _backOfficeUserManager.GetUserAsync(claimsPrincipleAttempt.Result!); + if (user == null) + { + return Redirect(_securitySettings.Value.BackOfficeHost + "/" + _securitySettings.Value.AuthorizeCallbackErrorPathName.TrimStart('/').AppendQueryStringToUrl( + $"{RedirectFlowParameter}=link-login", + $"{RedirectStatusParameter}=user-not-found")); + } + // Request a redirect to the external login provider to link a login for the current user var redirectUrl = Url.Action(nameof(ExternalLinkLoginCallback), this.GetControllerName()); // Configures the redirect URL and user identifier for the specified external login including xsrf data AuthenticationProperties properties = - _backOfficeSignInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _backOfficeUserManager.GetUserId(User)); + _backOfficeSignInManager.ConfigureExternalAuthenticationProperties(requestModel.Provider, redirectUrl, user.Id); - return Challenge(properties, provider); + return Challenge(properties, requestModel.Provider); } /// /// Callback path when the user initiates a link login request from the back office to the external provider from the - /// action + /// action /// /// /// An example of this is here @@ -222,110 +272,66 @@ public class BackOfficeController : SecurityControllerBase [MapToApiVersion("1.0")] public async Task ExternalLinkLoginCallback() { - var cookieAuthenticatedUserAttempt = - await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType); + Attempt, ExternalLoginOperationStatus> handleResult = await _externalLoginService.HandleLoginCallbackAsync(HttpContext); - if (cookieAuthenticatedUserAttempt.Succeeded == false) + if (handleResult.Success) { - return Redirect(_securitySettings.Value.AuthorizeCallbackErrorPathName.AppendQueryStringToUrl( - "flow=external-login-callback", - "status=unauthorized")); + return Redirect(_securitySettings.Value.BackOfficeHost?.GetLeftPart(UriPartial.Authority) ?? Constants.System.DefaultUmbracoPath); } - BackOfficeIdentityUser? user = await _backOfficeUserManager.GetUserAsync(cookieAuthenticatedUserAttempt.Principal); - if (user == null) + return handleResult.Status switch { - return Redirect(_securitySettings.Value.AuthorizeCallbackErrorPathName.AppendQueryStringToUrl( - "flow=external-login-callback", - "status=user-not-found")); - } + ExternalLoginOperationStatus.Unauthorized => RedirectWithStatus("unauthorized"), + ExternalLoginOperationStatus.UserNotFound => RedirectWithStatus("user-not-found"), + ExternalLoginOperationStatus.ExternalInfoNotFound => RedirectWithStatus("external-info-not-found"), + ExternalLoginOperationStatus.IdentityFailure => RedirectWithStatus("failed"), + _ => RedirectWithStatus("unknown-failure") + }; - ExternalLoginInfo? info = - await _backOfficeSignInManager.GetExternalLoginInfoAsync(); - - if (info == null) - { - return Redirect(_securitySettings.Value.AuthorizeCallbackErrorPathName.AppendQueryStringToUrl( - "flow=external-login-callback", - "status=external-info-not-found")); - } - - IdentityResult addLoginResult = await _backOfficeUserManager.AddLoginAsync(user, info); - if (addLoginResult.Succeeded) - { - // Update any authentication tokens if succeeded - await _backOfficeSignInManager.UpdateExternalAuthenticationTokensAsync(info); - return Redirect("/umbraco"); // todo shouldn't this come from configuration - } - - // Add errors and redirect for it to be displayed - // TempData[ViewDataExtensions.TokenExternalSignInError] = addLoginResult.Errors; - // return RedirectToLogin(new { flow = "external-login", status = "failed", logout = "true" }); - // todo - return Redirect(_securitySettings.Value.AuthorizeCallbackErrorPathName.AppendQueryStringToUrl( - "flow=external-login-callback", - "status=failed")); + RedirectResult RedirectWithStatus(string status) + => CallbackErrorRedirectWithStatus("external-login-callback", status, handleResult.Result); } - // todo cleanup unhappy responses [HttpPost("unlink-login")] [MapToApiVersion("1.0")] public async Task PostUnLinkLogin(UnLinkLoginRequestModel unlinkLoginRequestModel) { - var userId = User.Identity?.GetUserId(); - if (userId is null) - { - throw new InvalidOperationException("Could not find userId"); - } - - BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByIdAsync(userId); - if (user == null) - { - throw new InvalidOperationException("Could not find user"); - } - - AuthenticationScheme? authType = (await _backOfficeSignInManager.GetExternalAuthenticationSchemesAsync()) - .FirstOrDefault(x => x.Name == unlinkLoginRequestModel.LoginProvider); - - if (authType == null) - { - _logger.LogWarning("Could not find the supplied external authentication provider"); - } - else - { - BackOfficeExternaLoginProviderScheme? opt = await _backOfficeExternalLoginProviders.GetAsync(authType.Name); - if (opt == null) - { - return StatusCode(StatusCodes.Status400BadRequest, new ProblemDetailsBuilder() - .WithTitle("Missing Authentication options") - .WithDetail($"Could not find external authentication options registered for provider {authType.Name}") - .Build()); - } - - if (!opt.ExternalLoginProvider.Options.AutoLinkOptions.AllowManualLinking) - { - // If AllowManualLinking is disabled for this provider we cannot unlink - return StatusCode(StatusCodes.Status400BadRequest, new ProblemDetailsBuilder() - .WithTitle("Unlinking disabled") - .WithDetail($"Manual linking is disabled for provider {authType.Name}") - .Build()); - } - } - - IdentityResult result = await _backOfficeUserManager.RemoveLoginAsync( - user, + Attempt unlinkResult = await _externalLoginService.UnLinkLoginAsync( + User, unlinkLoginRequestModel.LoginProvider, unlinkLoginRequestModel.ProviderKey); - if (result.Succeeded) + if (unlinkResult.Success) { - await _backOfficeSignInManager.SignInAsync(user, true); return Ok(); } - return StatusCode(StatusCodes.Status400BadRequest, new ProblemDetailsBuilder() - .WithTitle("Unlinking failed") - .Build()); + return OperationStatusResult(unlinkResult.Result, problemDetailsBuilder => unlinkResult.Result switch + { + ExternalLoginOperationStatus.UserNotFound => Unauthorized(problemDetailsBuilder + .WithTitle("User not found") + .Build()), + ExternalLoginOperationStatus.IdentityNotFound => BadRequest(problemDetailsBuilder + .WithTitle("Missing identity") + .Build()), + ExternalLoginOperationStatus.AuthenticationSchemeNotFound => BadRequest(problemDetailsBuilder + .WithTitle("Authentication scheme not found") + .WithDetail("Could not find the authentication scheme for the supplied provider") + .Build()), + ExternalLoginOperationStatus.AuthenticationOptionsNotFound => BadRequest(problemDetailsBuilder + .WithTitle("Missing Authentication options") + .WithDetail("Could not find external authentication options for the supplied provider") + .Build()), + ExternalLoginOperationStatus.UnlinkingDisabled => BadRequest(problemDetailsBuilder + .WithTitle("Unlinking disabled") + .WithDetail("Manual linking is disabled for the supplied provider") + .Build()), + ExternalLoginOperationStatus.InvalidProviderKey => BadRequest(problemDetailsBuilder + .WithTitle("Unlinking failed") + .WithDetail("Could not match ProviderKey to the supplied provider") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown external login operation status."), + }); } /// @@ -416,4 +422,18 @@ public class BackOfficeController : SecurityControllerBase } private static IActionResult DefaultChallengeResult() => new ChallengeResult(Constants.Security.BackOfficeAuthenticationType); + + private RedirectResult CallbackErrorRedirectWithStatus( string flowType, string status, IEnumerable identityErrors) + { + var redirectUrl = _securitySettings.Value.BackOfficeHost + "/" + + _securitySettings.Value.AuthorizeCallbackErrorPathName.TrimStart('/').AppendQueryStringToUrl( + $"{RedirectFlowParameter}={flowType}", + $"{RedirectStatusParameter}={status}"); + foreach (IdentityError identityError in identityErrors) + { + redirectUrl.AppendQueryStringToUrl($"{RedirectErrorCodeParameter}={identityError.Code}"); + } + + return Redirect(redirectUrl); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/ListExternalLoginProvidersCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/ListExternalLoginProvidersCurrentUserController.cs new file mode 100644 index 0000000000..d0cf0e315c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/ListExternalLoginProvidersCurrentUserController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Services; +using Umbraco.Cms.Api.Management.ViewModels.User.Current; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.User.Current; + +[ApiVersion("1.0")] +public class ListExternalLoginProvidersCurrentUserController : CurrentUserControllerBase +{ + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IBackOfficeExternalLoginService _backOfficeExternalLoginService; + private readonly IUmbracoMapper _mapper; + + public ListExternalLoginProvidersCurrentUserController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IBackOfficeExternalLoginService backOfficeExternalLoginService, + IUmbracoMapper mapper) + { + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _backOfficeExternalLoginService = backOfficeExternalLoginService; + _mapper = mapper; + } + + [MapToApiVersion("1.0")] + [HttpGet("login-providers")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task ListTwoFactorProvidersForCurrentUser(CancellationToken cancellationToken) + { + Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor); + + Attempt, ExternalLoginOperationStatus> result = + await _backOfficeExternalLoginService.ExternalLoginStatusForUserAsync(userKey); + + return result.Success + ? Ok(_mapper.MapEnumerable(result.Result)) + : ExternalLoginOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs index 18eb49d4fe..3cad4367ca 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs @@ -153,4 +153,16 @@ public abstract class UserOrCurrentUserControllerBase : ManagementApiControllerB .WithTitle("Unknown two factor operation status.") .Build()), }); + + protected IActionResult ExternalLoginOperationStatusResult(ExternalLoginOperationStatus status) => + OperationStatusResult(status, problemDetailsBuilder => status switch + { + ExternalLoginOperationStatus.UserNotFound => NotFound(problemDetailsBuilder + .WithTitle("User not found") + .WithDetail("The specified user id was not found.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder + .WithTitle("Unknown two factor operation status.") + .Build()), + }); } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index f3ae55c8f6..6e0f79cf48 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Security; +using Umbraco.Cms.Api.Management.Services; using Umbraco.Cms.Api.Management.Telemetry; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; @@ -70,6 +71,8 @@ public static partial class UmbracoBuilderExtensions // Configure the options specifically for the UmbracoBackOfficeIdentityOptions instance services.ConfigureOptions(); + services.AddScoped(); + return builder; } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Users/UsersViewModelsMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Users/UsersViewModelsMapDefinition.cs index c33d9666a0..63213000e1 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Users/UsersViewModelsMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Users/UsersViewModelsMapDefinition.cs @@ -1,5 +1,6 @@ using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.User; +using Umbraco.Cms.Api.Management.ViewModels.User.Current; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; @@ -14,6 +15,8 @@ public class UsersViewModelsMapDefinition : IMapDefinition mapper.Define((_, _) => new ResetPasswordUserResponseModel(), Map); mapper.Define((_, _) => new CreateUserResponseModel { User = new() }, Map); mapper.Define((_, _) => new LinkedLoginViewModel { ProviderKey = string.Empty, ProviderName = string.Empty }, Map); + mapper.Define( + (_, _) => new UserExternalLoginProviderResponseModel { ProviderSchemeName = string.Empty }, Map); } // Umbraco.Code.MapAll @@ -26,10 +29,10 @@ public class UsersViewModelsMapDefinition : IMapDefinition // Umbraco.Code.MapAll private void Map(UserCreationResult source, CreateUserResponseModel target, MapperContext context) { - Guid userId = source.CreatedUser?.Key + Guid userKey = source.CreatedUser?.Key ?? throw new ArgumentException("Cannot map a user creation response without a created user", nameof(source)); - target.User = new ReferenceByIdModel(userId); + target.User = new ReferenceByIdModel(userKey); target.InitialPassword = source.InitialPassword; } @@ -38,4 +41,12 @@ public class UsersViewModelsMapDefinition : IMapDefinition { target.ResetPassword = source.ResetPassword; } + + // Umbraco.Code.MapAll + private void Map(UserExternalLoginProviderModel source, UserExternalLoginProviderResponseModel target, MapperContext context) + { + target.ProviderSchemeName = source.ProviderSchemeName; + target.HasManualLinkingEnabled = source.HasManualLinkingEnabled; + target.IsLinkedOnUser = source.IsLinkedOnUser; + } } diff --git a/src/Umbraco.Cms.Api.Management/Models/LoginProviderUserLink.cs b/src/Umbraco.Cms.Api.Management/Models/LoginProviderUserLink.cs new file mode 100644 index 0000000000..4debabc8f6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Models/LoginProviderUserLink.cs @@ -0,0 +1,10 @@ +using System.Security.Claims; + +namespace Umbraco.Cms.Api.Management.Models; + +public class LoginProviderUserLink +{ + public required string ClaimsPrincipalUserId { get; set; } + + public required string LoginProvider { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index e145fc3aab..635db997e9 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -30685,6 +30685,41 @@ ] } }, + "/umbraco/management/api/v1/user/current/login-providers": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrentLoginProviders", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserExternalLoginProviderModel" + } + ] + } + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/user/current/logins": { "get": { "tags": [ @@ -43536,6 +43571,26 @@ }, "additionalProperties": false }, + "UserExternalLoginProviderModel": { + "required": [ + "hasManualLinkingEnabled", + "isLinkedOnUser", + "providerSchemeName" + ], + "type": "object", + "properties": { + "providerSchemeName": { + "type": "string" + }, + "isLinkedOnUser": { + "type": "boolean" + }, + "hasManualLinkingEnabled": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "UserGroupItemResponseModel": { "required": [ "id", diff --git a/src/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginService.cs b/src/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginService.cs new file mode 100644 index 0000000000..3bed9791c7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginService.cs @@ -0,0 +1,212 @@ +using System.Security.Claims; +using System.Security.Principal; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Caching.Memory; +using Umbraco.Cms.Api.Management.Models; +using Umbraco.Cms.Api.Management.Security; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Services; + +public class BackOfficeExternalLoginService : IBackOfficeExternalLoginService +{ + private readonly IBackOfficeExternalLoginProviders _backOfficeExternalLoginProviders; + private readonly IUserService _userService; + private readonly IBackOfficeUserManager _backOfficeUserManager; + private readonly IBackOfficeSignInManager _backOfficeSignInManager; + private readonly IMemoryCache _memoryCache; + + public BackOfficeExternalLoginService( + IBackOfficeExternalLoginProviders backOfficeExternalLoginProviders, + IUserService userService, + IBackOfficeUserManager backOfficeUserManager, + IBackOfficeSignInManager backOfficeSignInManager, + IMemoryCache memoryCache) + { + _backOfficeExternalLoginProviders = backOfficeExternalLoginProviders; + _userService = userService; + _backOfficeUserManager = backOfficeUserManager; + _backOfficeSignInManager = backOfficeSignInManager; + _memoryCache = memoryCache; + } + + public async Task, ExternalLoginOperationStatus>> ExternalLoginStatusForUserAsync(Guid userKey) + { + IEnumerable providers = + await _backOfficeExternalLoginProviders.GetBackOfficeProvidersAsync(); + + Attempt, UserOperationStatus> linkedLoginsAttempt = + await _userService.GetLinkedLoginsAsync(userKey); + + if (linkedLoginsAttempt.Success is false) + { + return Attempt, ExternalLoginOperationStatus>.Fail( + FromUserOperationStatusFailure(linkedLoginsAttempt.Status), + Enumerable.Empty()); + } + + IEnumerable providerStatuses = providers.Select( + providerScheme => new UserExternalLoginProviderModel( + providerScheme.ExternalLoginProvider.AuthenticationType, + linkedLoginsAttempt.Result.Any(linkedLogin => + linkedLogin.LoginProvider == providerScheme.ExternalLoginProvider.AuthenticationType), + providerScheme.ExternalLoginProvider.Options.AutoLinkOptions.AllowManualLinking)); + + return Attempt, ExternalLoginOperationStatus>.Succeed( + ExternalLoginOperationStatus.Success, providerStatuses); + } + + public async Task> UnLinkLoginAsync(ClaimsPrincipal claimsPrincipal, string loginProvider, string providerKey) + { + var userId = claimsPrincipal.Identity?.GetUserId(); + if (userId is null) + { + return Attempt.Fail(ExternalLoginOperationStatus.IdentityNotFound); + } + + BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByIdAsync(userId); + if (user is null) + { + return Attempt.Fail(ExternalLoginOperationStatus.UserNotFound); + } + + AuthenticationScheme? authType = (await _backOfficeSignInManager.GetExternalAuthenticationSchemesAsync()) + .FirstOrDefault(x => x.Name == loginProvider); + + if (authType is null) + { + return Attempt.Fail(ExternalLoginOperationStatus.AuthenticationSchemeNotFound); + } + + BackOfficeExternaLoginProviderScheme? opt = await _backOfficeExternalLoginProviders.GetAsync(authType.Name); + if (opt is null) + { + return Attempt.Fail(ExternalLoginOperationStatus.AuthenticationOptionsNotFound); + } + + if (opt.ExternalLoginProvider.Options.AutoLinkOptions.AllowManualLinking is false) + { + return Attempt.Fail(ExternalLoginOperationStatus.UnlinkingDisabled); + } + + IEnumerable externalLogins = user.Logins.Where(l => l.LoginProvider == loginProvider); + if (externalLogins.Any(l => l.ProviderKey == providerKey) is false) + { + return Attempt.Fail(ExternalLoginOperationStatus.InvalidProviderKey); + } + + IdentityResult result = await _backOfficeUserManager.RemoveLoginAsync(user, loginProvider, providerKey); + + if (result.Succeeded is false) + { + return Attempt.Fail(ExternalLoginOperationStatus.Unknown); + } + + await _backOfficeSignInManager.SignInAsync(user, true); + return Attempt.Succeed(ExternalLoginOperationStatus.Success); + } + + public async Task, ExternalLoginOperationStatus>> HandleLoginCallbackAsync(HttpContext httpContext) + { + AuthenticateResult cookieAuthenticatedUserAttempt = + await httpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType); + + if (cookieAuthenticatedUserAttempt.Succeeded is false) + { + return Attempt.FailWithStatus(ExternalLoginOperationStatus.Unauthorized, Enumerable.Empty()); + } + + BackOfficeIdentityUser? user = await _backOfficeUserManager.GetUserAsync(cookieAuthenticatedUserAttempt.Principal); + if (user is null) + { + return Attempt.FailWithStatus(ExternalLoginOperationStatus.UserNotFound, Enumerable.Empty()); + } + + ExternalLoginInfo? info = await _backOfficeSignInManager.GetExternalLoginInfoAsync(); + + if (info is null) + { + return Attempt.FailWithStatus(ExternalLoginOperationStatus.ExternalInfoNotFound, Enumerable.Empty()); + } + + IdentityResult addLoginResult = await _backOfficeUserManager.AddLoginAsync(user, info); + if (addLoginResult.Succeeded is false) + { + return Attempt.FailWithStatus(ExternalLoginOperationStatus.IdentityFailure, addLoginResult.Errors); + } + + // Update any authentication tokens if succeeded + await _backOfficeSignInManager.UpdateExternalAuthenticationTokensAsync(info); + return Attempt.SucceedWithStatus(ExternalLoginOperationStatus.Success, Enumerable.Empty()); + } + + public async Task> GenerateLoginProviderSecretAsync(ClaimsPrincipal claimsPrincipal, string loginProvider) + { + if (claimsPrincipal.Identity is null) + { + return Attempt.FailWithStatus(ExternalLoginOperationStatus.IdentityNotFound, null); + } + + IEnumerable configuredLoginProviders = await _backOfficeExternalLoginProviders.GetBackOfficeProvidersAsync(); + if (configuredLoginProviders.Any(provider => provider.ExternalLoginProvider.AuthenticationType.Equals(loginProvider)) + is false) + { + return Attempt.FailWithStatus(ExternalLoginOperationStatus.AuthenticationSchemeNotFound, null); + } + + var userId = claimsPrincipal.Identity.GetUserId(); + if (userId is null) + { + return Attempt.FailWithStatus(ExternalLoginOperationStatus.IdentityNotFound, null); + } + + var secret = Guid.NewGuid(); + _memoryCache.Set(secret, new LoginProviderUserLink { ClaimsPrincipalUserId = userId, LoginProvider = loginProvider }, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30) }); + + return Attempt.Succeed(ExternalLoginOperationStatus.Success, secret); + } + + public async Task> ClaimsPrincipleFromLoginProviderLinkKeyAsync( + string loginProvider, + Guid linkKey) + { + LoginProviderUserLink? cachedSecretValue = _memoryCache.Get(linkKey); + if (cachedSecretValue is null) + { + return Attempt.FailWithStatus(ExternalLoginOperationStatus.UserSecretNotFound, null); + } + + if (cachedSecretValue.LoginProvider.Equals(loginProvider) is false) + { + return Attempt.FailWithStatus( + ExternalLoginOperationStatus.InvalidSecret, null); + } + + BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByIdAsync(cachedSecretValue.ClaimsPrincipalUserId); + if (user is null) + { + return Attempt.FailWithStatus( + ExternalLoginOperationStatus.IdentityNotFound, null); + } + + ClaimsPrincipal claimsPrinciple = await _backOfficeSignInManager.CreateUserPrincipalAsync(user); + + _memoryCache.Remove(linkKey); + return Attempt.SucceedWithStatus(ExternalLoginOperationStatus.Success, claimsPrinciple); + } + + private ExternalLoginOperationStatus FromUserOperationStatusFailure(UserOperationStatus userOperationStatus) => + userOperationStatus switch + { + UserOperationStatus.MissingUser => ExternalLoginOperationStatus.UserNotFound, + _ => ExternalLoginOperationStatus.Unknown + }; +} diff --git a/src/Umbraco.Cms.Api.Management/Services/IBackOfficeExternalLoginService.cs b/src/Umbraco.Cms.Api.Management/Services/IBackOfficeExternalLoginService.cs new file mode 100644 index 0000000000..7b14656bf2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/IBackOfficeExternalLoginService.cs @@ -0,0 +1,24 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Services; + +public interface IBackOfficeExternalLoginService +{ + Task, ExternalLoginOperationStatus>> ExternalLoginStatusForUserAsync(Guid userKey); + + Task> UnLinkLoginAsync(ClaimsPrincipal claimsPrincipal, string loginProvider, string providerKey); + + Task, ExternalLoginOperationStatus>> HandleLoginCallbackAsync(HttpContext httpContext); + + Task> GenerateLoginProviderSecretAsync(ClaimsPrincipal claimsPrincipal, + string loginProvider); + + Task> ClaimsPrincipleFromLoginProviderLinkKeyAsync( + string loginProvider, + Guid linkKey); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Security/LinkLoginRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Security/LinkLoginRequestModel.cs new file mode 100644 index 0000000000..0737d685fa --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Security/LinkLoginRequestModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Security; + +public class LinkLoginRequestModel +{ + public required string Provider { get; set; } + + public required Guid LinkKey { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/UserExternalLoginProviderResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/UserExternalLoginProviderResponseModel.cs new file mode 100644 index 0000000000..edca47ce90 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/UserExternalLoginProviderResponseModel.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.User.Current; + +public class UserExternalLoginProviderResponseModel +{ + public required string ProviderSchemeName { get; set; } + + public bool IsLinkedOnUser { get; set; } + + public bool HasManualLinkingEnabled { get; set; } +} diff --git a/src/Umbraco.Core/Models/UserExternalLoginProviderModel.cs b/src/Umbraco.Core/Models/UserExternalLoginProviderModel.cs new file mode 100644 index 0000000000..2f5d48fd78 --- /dev/null +++ b/src/Umbraco.Core/Models/UserExternalLoginProviderModel.cs @@ -0,0 +1,17 @@ +namespace Umbraco.Cms.Core.Models; + +public class UserExternalLoginProviderModel +{ + public UserExternalLoginProviderModel(string providerSchemeName, bool isLinkedOnUser, bool hasManualLinkingEnabled) + { + ProviderSchemeName = providerSchemeName; + IsLinkedOnUser = isLinkedOnUser; + HasManualLinkingEnabled = hasManualLinkingEnabled; + } + + public string ProviderSchemeName { get; } + + public bool IsLinkedOnUser { get; } + + public bool HasManualLinkingEnabled { get; } +} diff --git a/src/Umbraco.Core/Services/OperationStatus/ExternalLoginOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ExternalLoginOperationStatus.cs new file mode 100644 index 0000000000..59bc2f6a64 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/ExternalLoginOperationStatus.cs @@ -0,0 +1,18 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum ExternalLoginOperationStatus +{ + Success, + UserNotFound, + Unknown, + IdentityNotFound, + AuthenticationOptionsNotFound, + UnlinkingDisabled, + InvalidProviderKey, + AuthenticationSchemeNotFound, + Unauthorized, + ExternalInfoNotFound, + IdentityFailure, + UserSecretNotFound, + InvalidSecret +} diff --git a/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs b/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs index 803ee92b13..255187babc 100644 --- a/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs +++ b/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs @@ -6,6 +6,10 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Web.Common.Helpers; +/// +/// This class seems unused, but is used by implementors to configure the error flow for external login providers +/// so that they properly route towards our default error handling page with the correct parameters. +/// public class OAuthOptionsHelper { // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 @@ -48,7 +52,7 @@ public class OAuthOptionsHelper public T SetUmbracoRedirectWithFilteredParams(T context, string providerFriendlyName, string eventName) where T : HandleRequestContext { - var callbackPath = _securitySettings.Value.AuthorizeCallbackErrorPathName; + var callbackPath =_securitySettings.Value.BackOfficeHost + _securitySettings.Value.AuthorizeCallbackErrorPathName; callbackPath = callbackPath.AppendQueryStringToUrl("flow=external-login") .AppendQueryStringToUrl($"provider={providerFriendlyName}") diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.ExternalLoginStatusForUserAsync.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.ExternalLoginStatusForUserAsync.cs new file mode 100644 index 0000000000..414938991a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.ExternalLoginStatusForUserAsync.cs @@ -0,0 +1,102 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Services; + +public partial class BackOfficeExternalLoginServiceTests +{ + [Test] + public async Task ExternalLoginStatusForUser_Returns_All_Registered_Providers() + { + // arrange + var userId = Guid.NewGuid(); + var firstProviderName = "one"; + var secondProviderName = "two"; + var serviceSetup = new BackOfficeExternalLoginServiceSetup(); + serviceSetup.BackOfficeLoginProviders + .Setup(s => s.GetBackOfficeProvidersAsync()) + .ReturnsAsync(new[] + { + TestExternalLoginProviderScheme(firstProviderName, true), + TestExternalLoginProviderScheme(secondProviderName, true) + }); + serviceSetup.UserService + .Setup(s => s.GetLinkedLoginsAsync(userId)) + .ReturnsAsync(Attempt.SucceedWithStatus, UserOperationStatus>( + UserOperationStatus.Success, Array.Empty())); + + var externalLoginService = serviceSetup.Sut; + + // act + var providersAttempt = await externalLoginService.ExternalLoginStatusForUserAsync(userId); + + Assert.True(providersAttempt.Success); + Assert.Multiple(() => + { + Assert.AreEqual(1, providersAttempt.Result.Count(p => p.ProviderSchemeName.Equals(firstProviderName))); + Assert.AreEqual(1, providersAttempt.Result.Count(p => p.ProviderSchemeName.Equals(secondProviderName))); + Assert.AreEqual(2, providersAttempt.Result.Count()); + }); + } + + [Test] + public async Task ExternalLoginStatusForUser_Incorporates_Linked_Logins() + { + // arrange + var userId = Guid.NewGuid(); + var firstProviderName = "one"; + var secondProviderName = "two"; + var serviceSetup = new BackOfficeExternalLoginServiceSetup(); + serviceSetup.BackOfficeLoginProviders + .Setup(s => s.GetBackOfficeProvidersAsync()) + .ReturnsAsync(new[] + { + TestExternalLoginProviderScheme(firstProviderName, true), + TestExternalLoginProviderScheme(secondProviderName, true) + }); + serviceSetup.UserService + .Setup(s => s.GetLinkedLoginsAsync(userId)) + .ReturnsAsync(Attempt.SucceedWithStatus, UserOperationStatus>( + UserOperationStatus.Success, + new[] { new IdentityUserLogin(firstProviderName, firstProviderName + "ProvKey", userId.ToString()) })); + + var externalLoginService = serviceSetup.Sut; + + // act + var providersAttempt = await externalLoginService.ExternalLoginStatusForUserAsync(userId); + + Assert.True(providersAttempt.Success); + Assert.IsTrue(providersAttempt.Result.Single(p => p.ProviderSchemeName == firstProviderName).IsLinkedOnUser); + } + + [TestCase(true)] + [TestCase(false)] + public async Task ExternalLoginStatusForUser_Returns_Correct_AllowManualLinking(bool allowManualLinking) + { + // arrange + var userId = Guid.NewGuid(); + var providerName = "one"; + var serviceSetup = new BackOfficeExternalLoginServiceSetup(); + serviceSetup.BackOfficeLoginProviders + .Setup(s => s.GetBackOfficeProvidersAsync()) + .ReturnsAsync(new[] { TestExternalLoginProviderScheme(providerName, allowManualLinking), }); + serviceSetup.UserService + .Setup(s => s.GetLinkedLoginsAsync(userId)) + .ReturnsAsync(Attempt.SucceedWithStatus, UserOperationStatus>( + UserOperationStatus.Success, + new[] { new IdentityUserLogin(providerName, providerName + "ProvKey", userId.ToString()) })); + + var externalLoginService = serviceSetup.Sut; + + // act + var providersAttempt = await externalLoginService.ExternalLoginStatusForUserAsync(userId); + + Assert.True(providersAttempt.Success); + Assert.AreEqual( + allowManualLinking, + providersAttempt.Result.Single(p => p.ProviderSchemeName == providerName).HasManualLinkingEnabled); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.UnLinkLoginAsync.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.UnLinkLoginAsync.cs new file mode 100644 index 0000000000..d6d3d6c92a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.UnLinkLoginAsync.cs @@ -0,0 +1,146 @@ +using System.Runtime.InteropServices.JavaScript; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Security; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Services; + +public partial class BackOfficeExternalLoginServiceTests +{ + [TestCase(ExternalLoginOperationStatus.Success, true, true, true, true, true, true, true)] + [TestCase(ExternalLoginOperationStatus.IdentityNotFound, false, true, true, true, true, true, true)] + [TestCase(ExternalLoginOperationStatus.UserNotFound, true, false, true, true, true, true, true)] + [TestCase(ExternalLoginOperationStatus.AuthenticationSchemeNotFound, true, true, false, true, true, true, true)] + [TestCase(ExternalLoginOperationStatus.AuthenticationOptionsNotFound, true, true, true, false, true, true, true)] + [TestCase(ExternalLoginOperationStatus.UnlinkingDisabled, true, true, true, true, false, true, true)] + [TestCase(ExternalLoginOperationStatus.InvalidProviderKey, true, true, true, true, true, false, true)] + [TestCase(ExternalLoginOperationStatus.Unknown, true, true, true, true, true, true, false)] + public async Task UnLinkLogin_Returns_Correct_Status( + ExternalLoginOperationStatus expectedResult, + bool claimsPrincipalHasIdentity, + bool backOfficeManagerCanFindUser, + bool authenticationSchemeCanBeFound, + bool authenticationOptionsCanBeFound, + bool manualLinkingIsEnabled, + bool userHasMatchingLoginConfigured, + bool removeLoginPasses) + { + // arrange + var serviceSetup = new BackOfficeExternalLoginServiceSetup(); + var userId = Guid.NewGuid(); + var loginProviderName = "one"; + var providerKeyPostFix = "provKey"; + + var claimsPrinciple = BuildClaimsPrinciple(claimsPrincipalHasIdentity ? userId : null); + SetupBackOfficeIdentityUser( + serviceSetup, + backOfficeManagerCanFindUser ? userId : null, + userHasMatchingLoginConfigured ? [loginProviderName] : null, + providerKeyPostFix); + SetupAuthenticationScheme(serviceSetup, authenticationSchemeCanBeFound ? loginProviderName : null); + SetupExternalLoginProviderScheme( + serviceSetup, + authenticationOptionsCanBeFound ? loginProviderName : null, + manualLinkingIsEnabled); + SetupRemoveLoginAsync(serviceSetup, removeLoginPasses); + + var externalLoginService = serviceSetup.Sut; + + // act + var providersAttempt = + await externalLoginService.UnLinkLoginAsync( + claimsPrinciple, + loginProviderName, + ProviderKey(loginProviderName, providerKeyPostFix)); + + // assert + Assert.AreEqual(expectedResult, providersAttempt.Result); + } + + private ClaimsPrincipal BuildClaimsPrinciple(Guid? identityId) + { + var identity = new ClaimsIdentity(new ClaimsIdentity(identityId is not null + ? new[] { new Claim(ClaimTypes.NameIdentifier, identityId.ToString()) } + : Enumerable.Empty())); + + var claimsPrinciple = new ClaimsPrincipal(identity); + return claimsPrinciple; + } + + private void SetupBackOfficeIdentityUser( + BackOfficeExternalLoginServiceSetup serviceSetup, + Guid? userId, + string[]? configuredLoginProviderNames, + string providerKeyNamePostfix) => + serviceSetup.BackOfficeUserManager + .Setup(bum => bum.FindByIdAsync(It.IsAny())) + .ReturnsAsync(userId is null + ? null + : () => + { + var mock = new BackOfficeIdentityUser(Mock.Of(), -1, + Enumerable.Empty()); + mock.Key = userId.Value; + mock.SetLoginsCallback(new Lazy?>(() => + configuredLoginProviderNames is null + ? Array.Empty() + : configuredLoginProviderNames.Select(provName => + new IdentityUserLogin( + provName, + ProviderKey(provName, providerKeyNamePostfix), + userId.ToString())) + .ToArray())); + + return mock; + }); + + private void SetupAuthenticationScheme(BackOfficeExternalLoginServiceSetup serviceSetup, string? authTypeName) + { + var authSchemas = new List(); + if (authTypeName is not null) + { + authSchemas.Add(TestAuthenticationScheme(authTypeName)); + } + + serviceSetup.BackOfficeSignInManager + .Setup(manager => manager.GetExternalAuthenticationSchemesAsync()) + .ReturnsAsync(authSchemas); + } + + private void SetupExternalLoginProviderScheme( + BackOfficeExternalLoginServiceSetup serviceSetup, + string? authTypeName, + bool allowManualLinking) + { + if (authTypeName is null) + { + serviceSetup.BackOfficeLoginProviders + .Setup(lp => lp.GetAsync(It.IsAny())) + .ReturnsAsync((BackOfficeExternaLoginProviderScheme?)null); + return; + } + + var mock = TestExternalLoginProviderScheme(authTypeName, allowManualLinking); + + serviceSetup.BackOfficeLoginProviders + .Setup(lp => lp.GetAsync(authTypeName)) + .ReturnsAsync(mock); + } + + private void SetupRemoveLoginAsync(BackOfficeExternalLoginServiceSetup serviceSetup, bool shouldSucceed) => + serviceSetup.BackOfficeUserManager + .Setup(manager => manager.RemoveLoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(shouldSucceed ? IdentityResult.Success : IdentityResult.Failed()); + + private string ProviderKey(string providerName, string providerKeyPostFix) => providerName + providerKeyPostFix; +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.cs new file mode 100644 index 0000000000..9d63744513 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/BackOfficeExternalLoginServiceTests.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Security; +using Umbraco.Cms.Api.Management.Services; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common; +using Umbraco.Cms.Web.Common.Security; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Services; + +[TestFixture] +public partial class BackOfficeExternalLoginServiceTests +{ + private class BackOfficeExternalLoginServiceSetup + { + private BackOfficeExternalLoginService? _sut; + + public BackOfficeExternalLoginService Sut => + _sut ??= new BackOfficeExternalLoginService( + BackOfficeLoginProviders.Object, + UserService.Object, + BackOfficeUserManager.Object, + BackOfficeSignInManager.Object, + MemoryCache.Object); + + public Mock BackOfficeLoginProviders { get; } = new(); + + public Mock UserService { get; } = new(); + + public Mock BackOfficeUserManager { get; } = new(); + + public Mock BackOfficeSignInManager { get; } = new(); + + public Mock MemoryCache { get; } = new(); + } + + private class MockAuthenticationHandler : IAuthenticationHandler + { + public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) => + throw new NotImplementedException(); + + public Task AuthenticateAsync() => throw new NotImplementedException(); + + public Task ChallengeAsync(AuthenticationProperties? properties) => throw new NotImplementedException(); + + public Task ForbidAsync(AuthenticationProperties? properties) => throw new NotImplementedException(); + } + + private BackOfficeExternaLoginProviderScheme TestExternalLoginProviderScheme( + string providerName, + bool allowManualLinking, + AuthenticationScheme? authScheme = null) => new BackOfficeExternaLoginProviderScheme( + new BackOfficeExternalLoginProvider( + providerName, + new TestOptionsMonitor( + new BackOfficeExternalLoginProviderOptions( + new ExternalSignInAutoLinkOptions(allowManualLinking: allowManualLinking)))), + authScheme ?? TestAuthenticationScheme()); + + private AuthenticationScheme TestAuthenticationScheme(string name = "test", string displayName = "test") => + new AuthenticationScheme(name, displayName, typeof(MockAuthenticationHandler)); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index 5d0c16dd66..a411bad38c 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -20,6 +20,12 @@ + + BackOfficeExternalLoginServiceTests.cs + + + BackOfficeExternalLoginServiceTests.cs +