[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 <sge@umbraco.dk>
Co-authored-by: kjac <kja@umbraco.dk>
Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
This commit is contained in:
Sven Geusens
2024-05-14 15:55:32 +02:00
committed by GitHub
parent 7f654a1c63
commit 742307dc32
18 changed files with 864 additions and 95 deletions

View File

@@ -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<BackOfficeController> _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<BackOfficeController> 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<IActionResult> LinkLoginKey(string provider)
{
Attempt<Guid?, ExternalLoginOperationStatus> 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();
}
/// <summary>
/// Called when a user links an external login provider in the back office
/// </summary>
/// <param name="provider"></param>
/// <returns></returns>
// 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<IActionResult> LinkLogin([FromForm] LinkLoginRequestModel requestModel)
{
Attempt<ClaimsPrincipal?, ExternalLoginOperationStatus> 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);
}
/// <summary>
/// Callback path when the user initiates a link login request from the back office to the external provider from the
/// <see cref="LinkLogin(string)" /> action
/// <see cref="LinkLogin(LinkLoginRequestModel)" /> action
/// </summary>
/// <remarks>
/// An example of this is here
@@ -222,110 +272,66 @@ public class BackOfficeController : SecurityControllerBase
[MapToApiVersion("1.0")]
public async Task<IActionResult> ExternalLinkLoginCallback()
{
var cookieAuthenticatedUserAttempt =
await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
Attempt<IEnumerable<IdentityError>, 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<IActionResult> 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<ExternalLoginOperationStatus> 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."),
});
}
/// <summary>
@@ -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<IdentityError> 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);
}
}

View File

@@ -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<UserExternalLoginProviderModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> ListTwoFactorProvidersForCurrentUser(CancellationToken cancellationToken)
{
Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor);
Attempt<IEnumerable<UserExternalLoginProviderModel>, ExternalLoginOperationStatus> result =
await _backOfficeExternalLoginService.ExternalLoginStatusForUserAsync(userKey);
return result.Success
? Ok(_mapper.MapEnumerable<UserExternalLoginProviderModel, UserExternalLoginProviderResponseModel>(result.Result))
: ExternalLoginOperationStatusResult(result.Status);
}
}

View File

@@ -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()),
});
}

View File

@@ -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<ConfigureBackOfficeIdentityOptions>();
services.AddScoped<IBackOfficeExternalLoginService, BackOfficeExternalLoginService>();
return builder;
}

View File

@@ -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<PasswordChangedModel, ResetPasswordUserResponseModel>((_, _) => new ResetPasswordUserResponseModel(), Map);
mapper.Define<UserCreationResult, CreateUserResponseModel>((_, _) => new CreateUserResponseModel { User = new() }, Map);
mapper.Define<IIdentityUserLogin, LinkedLoginViewModel>((_, _) => new LinkedLoginViewModel { ProviderKey = string.Empty, ProviderName = string.Empty }, Map);
mapper.Define<UserExternalLoginProviderModel, UserExternalLoginProviderResponseModel>(
(_, _) => 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;
}
}

View File

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

View File

@@ -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",

View File

@@ -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<Attempt<IEnumerable<UserExternalLoginProviderModel>, ExternalLoginOperationStatus>> ExternalLoginStatusForUserAsync(Guid userKey)
{
IEnumerable<BackOfficeExternaLoginProviderScheme> providers =
await _backOfficeExternalLoginProviders.GetBackOfficeProvidersAsync();
Attempt<ICollection<IIdentityUserLogin>, UserOperationStatus> linkedLoginsAttempt =
await _userService.GetLinkedLoginsAsync(userKey);
if (linkedLoginsAttempt.Success is false)
{
return Attempt<IEnumerable<UserExternalLoginProviderModel>, ExternalLoginOperationStatus>.Fail(
FromUserOperationStatusFailure(linkedLoginsAttempt.Status),
Enumerable.Empty<UserExternalLoginProviderModel>());
}
IEnumerable<UserExternalLoginProviderModel> 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<IEnumerable<UserExternalLoginProviderModel>, ExternalLoginOperationStatus>.Succeed(
ExternalLoginOperationStatus.Success, providerStatuses);
}
public async Task<Attempt<ExternalLoginOperationStatus>> 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<IIdentityUserLogin> 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<Attempt<IEnumerable<IdentityError>, ExternalLoginOperationStatus>> HandleLoginCallbackAsync(HttpContext httpContext)
{
AuthenticateResult cookieAuthenticatedUserAttempt =
await httpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
if (cookieAuthenticatedUserAttempt.Succeeded is false)
{
return Attempt.FailWithStatus(ExternalLoginOperationStatus.Unauthorized, Enumerable.Empty<IdentityError>());
}
BackOfficeIdentityUser? user = await _backOfficeUserManager.GetUserAsync(cookieAuthenticatedUserAttempt.Principal);
if (user is null)
{
return Attempt.FailWithStatus(ExternalLoginOperationStatus.UserNotFound, Enumerable.Empty<IdentityError>());
}
ExternalLoginInfo? info = await _backOfficeSignInManager.GetExternalLoginInfoAsync();
if (info is null)
{
return Attempt.FailWithStatus(ExternalLoginOperationStatus.ExternalInfoNotFound, Enumerable.Empty<IdentityError>());
}
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<IdentityError>());
}
public async Task<Attempt<Guid?, ExternalLoginOperationStatus>> GenerateLoginProviderSecretAsync(ClaimsPrincipal claimsPrincipal, string loginProvider)
{
if (claimsPrincipal.Identity is null)
{
return Attempt.FailWithStatus<Guid?, ExternalLoginOperationStatus>(ExternalLoginOperationStatus.IdentityNotFound, null);
}
IEnumerable<BackOfficeExternaLoginProviderScheme> configuredLoginProviders = await _backOfficeExternalLoginProviders.GetBackOfficeProvidersAsync();
if (configuredLoginProviders.Any(provider => provider.ExternalLoginProvider.AuthenticationType.Equals(loginProvider))
is false)
{
return Attempt.FailWithStatus<Guid?, ExternalLoginOperationStatus>(ExternalLoginOperationStatus.AuthenticationSchemeNotFound, null);
}
var userId = claimsPrincipal.Identity.GetUserId();
if (userId is null)
{
return Attempt.FailWithStatus<Guid?, ExternalLoginOperationStatus>(ExternalLoginOperationStatus.IdentityNotFound, null);
}
var secret = Guid.NewGuid();
_memoryCache.Set(secret, new LoginProviderUserLink { ClaimsPrincipalUserId = userId, LoginProvider = loginProvider }, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30) });
return Attempt<Guid?, ExternalLoginOperationStatus>.Succeed(ExternalLoginOperationStatus.Success, secret);
}
public async Task<Attempt<ClaimsPrincipal?, ExternalLoginOperationStatus>> ClaimsPrincipleFromLoginProviderLinkKeyAsync(
string loginProvider,
Guid linkKey)
{
LoginProviderUserLink? cachedSecretValue = _memoryCache.Get<LoginProviderUserLink>(linkKey);
if (cachedSecretValue is null)
{
return Attempt.FailWithStatus<ClaimsPrincipal?, ExternalLoginOperationStatus>(ExternalLoginOperationStatus.UserSecretNotFound, null);
}
if (cachedSecretValue.LoginProvider.Equals(loginProvider) is false)
{
return Attempt.FailWithStatus<ClaimsPrincipal?, ExternalLoginOperationStatus>(
ExternalLoginOperationStatus.InvalidSecret, null);
}
BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByIdAsync(cachedSecretValue.ClaimsPrincipalUserId);
if (user is null)
{
return Attempt.FailWithStatus<ClaimsPrincipal?, ExternalLoginOperationStatus>(
ExternalLoginOperationStatus.IdentityNotFound, null);
}
ClaimsPrincipal claimsPrinciple = await _backOfficeSignInManager.CreateUserPrincipalAsync(user);
_memoryCache.Remove(linkKey);
return Attempt.SucceedWithStatus<ClaimsPrincipal?, ExternalLoginOperationStatus>(ExternalLoginOperationStatus.Success, claimsPrinciple);
}
private ExternalLoginOperationStatus FromUserOperationStatusFailure(UserOperationStatus userOperationStatus) =>
userOperationStatus switch
{
UserOperationStatus.MissingUser => ExternalLoginOperationStatus.UserNotFound,
_ => ExternalLoginOperationStatus.Unknown
};
}

View File

@@ -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<Attempt<IEnumerable<UserExternalLoginProviderModel>, ExternalLoginOperationStatus>> ExternalLoginStatusForUserAsync(Guid userKey);
Task<Attempt<ExternalLoginOperationStatus>> UnLinkLoginAsync(ClaimsPrincipal claimsPrincipal, string loginProvider, string providerKey);
Task<Attempt<IEnumerable<IdentityError>, ExternalLoginOperationStatus>> HandleLoginCallbackAsync(HttpContext httpContext);
Task<Attempt<Guid?, ExternalLoginOperationStatus>> GenerateLoginProviderSecretAsync(ClaimsPrincipal claimsPrincipal,
string loginProvider);
Task<Attempt<ClaimsPrincipal?, ExternalLoginOperationStatus>> ClaimsPrincipleFromLoginProviderLinkKeyAsync(
string loginProvider,
Guid linkKey);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,10 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Web.Common.Helpers;
/// <remark>
/// 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.
/// </remark>
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>(T context, string providerFriendlyName, string eventName)
where T : HandleRequestContext<RemoteAuthenticationOptions>
{
var callbackPath = _securitySettings.Value.AuthorizeCallbackErrorPathName;
var callbackPath =_securitySettings.Value.BackOfficeHost + _securitySettings.Value.AuthorizeCallbackErrorPathName;
callbackPath = callbackPath.AppendQueryStringToUrl("flow=external-login")
.AppendQueryStringToUrl($"provider={providerFriendlyName}")

View File

@@ -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<ICollection<IIdentityUserLogin>, UserOperationStatus>(
UserOperationStatus.Success, Array.Empty<IIdentityUserLogin>()));
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<ICollection<IIdentityUserLogin>, 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<ICollection<IIdentityUserLogin>, 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);
}
}

View File

@@ -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<Claim>()));
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<string>()))
.ReturnsAsync(userId is null
? null
: () =>
{
var mock = new BackOfficeIdentityUser(Mock.Of<GlobalSettings>(), -1,
Enumerable.Empty<IReadOnlyUserGroup>());
mock.Key = userId.Value;
mock.SetLoginsCallback(new Lazy<IEnumerable<IIdentityUserLogin>?>(() =>
configuredLoginProviderNames is null
? Array.Empty<IdentityUserLogin>()
: 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<AuthenticationScheme>();
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<string>()))
.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<BackOfficeIdentityUser>(),
It.IsAny<string>(),
It.IsAny<string>()))
.ReturnsAsync(shouldSucceed ? IdentityResult.Success : IdentityResult.Failed());
private string ProviderKey(string providerName, string providerKeyPostFix) => providerName + providerKeyPostFix;
}

View File

@@ -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<IBackOfficeExternalLoginProviders> BackOfficeLoginProviders { get; } = new();
public Mock<IUserService> UserService { get; } = new();
public Mock<IBackOfficeUserManager> BackOfficeUserManager { get; } = new();
public Mock<IBackOfficeSignInManager> BackOfficeSignInManager { get; } = new();
public Mock<IMemoryCache> MemoryCache { get; } = new();
}
private class MockAuthenticationHandler : IAuthenticationHandler
{
public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) =>
throw new NotImplementedException();
public Task<AuthenticateResult> 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<BackOfficeExternalLoginProviderOptions>(
new BackOfficeExternalLoginProviderOptions(
new ExternalSignInAutoLinkOptions(allowManualLinking: allowManualLinking)))),
authScheme ?? TestAuthenticationScheme());
private AuthenticationScheme TestAuthenticationScheme(string name = "test", string displayName = "test") =>
new AuthenticationScheme(name, displayName, typeof(MockAuthenticationHandler));
}

View File

@@ -20,6 +20,12 @@
<ItemGroup>
<Compile Remove="Umbraco.Core\DeliveryApi\NestedContentValueConverterTests.cs" />
<Compile Remove="Umbraco.Core\PropertyEditors\NestedContentPropertyComponentTests.cs" />
<Compile Update="Umbraco.Cms.Api.Management\Services\BackOfficeExternalLoginServiceTests.ExternalLoginStatusForUserAsync.cs">
<DependentUpon>BackOfficeExternalLoginServiceTests.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Cms.Api.Management\Services\BackOfficeExternalLoginServiceTests.UnLinkLoginAsync.cs">
<DependentUpon>BackOfficeExternalLoginServiceTests.cs</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>