[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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
17
src/Umbraco.Core/Models/UserExternalLoginProviderModel.cs
Normal file
17
src/Umbraco.Core/Models/UserExternalLoginProviderModel.cs
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user