diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs index 916cc30c04..d0b68d81d2 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; +using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Routing; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -17,6 +18,7 @@ using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Extensions; using IdentitySignInResult = Microsoft.AspNetCore.Identity.SignInResult; using SignInResult = Microsoft.AspNetCore.Mvc.SignInResult; +using Umbraco.Cms.Core.Models; namespace Umbraco.Cms.Api.Management.Controllers.Security; @@ -59,13 +61,76 @@ public class BackOfficeController : SecurityControllerBase return Unauthorized(); } - var claims = new List { new(ClaimTypes.Name, model.Username) }; - var claimsIdentity = new ClaimsIdentity(claims, Constants.Security.NewBackOfficeAuthenticationType); - await HttpContext.SignInAsync(Constants.Security.NewBackOfficeAuthenticationType, new ClaimsPrincipal(claimsIdentity)); + IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync( + model.Username, model.Password, true, true); + if (result.IsNotAllowed) + { + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() + .WithTitle("User is not allowed") + .WithDetail("The operation is not allowed on the user") + .Build()); + } + if (result.IsLockedOut) + { + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() + .WithTitle("User is locked") + .WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.") + .Build()); + } + if(result.RequiresTwoFactor) + { + return StatusCode(StatusCodes.Status402PaymentRequired, new ProblemDetailsBuilder() + .WithTitle("2FA Required") + .WithDetail("The user is protected by 2FA. Please continue the login process and verify a 2FA code.") + .Build()); + } return Ok(); } + [HttpPost("verify-2fa")] + [MapToApiVersion("1.0")] + public async Task Verify2FACode(Verify2FACodeModel model) + { + if (ModelState.IsValid == false) + { + return BadRequest(); + } + + BackOfficeIdentityUser? user = await _backOfficeSignInManager.GetTwoFactorAuthenticationUserAsync(); + if (user is null) + { + return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder() + .WithTitle("No user found") + .Build()); + } + + IdentitySignInResult result = + await _backOfficeSignInManager.TwoFactorSignInAsync(model.Provider, model.Code, model.IsPersistent, model.RememberClient); + if (result.Succeeded) + { + return Ok(); + } + + if (result.IsLockedOut) + { + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() + .WithTitle("User is locked.") + .Build()); + } + + if (result.IsNotAllowed) + { + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() + .WithTitle("User is not allowed") + .Build()); + } + + return StatusCode(StatusCodes.Status400BadRequest, new ProblemDetailsBuilder() + .WithTitle("Invalid code") + .Build()); + } + public class LoginRequestModel { public required string Username { get; init; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/CurrentUserControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/CurrentUserControllerBase.cs index 118f6326f3..b8802fa0c1 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/CurrentUserControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/CurrentUserControllerBase.cs @@ -5,8 +5,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.User.Current; [ApiController] [VersionedApiBackOfficeRoute("user/current")] -[ApiExplorerSettings(GroupName = "User")] -public abstract class CurrentUserControllerBase : UserControllerBase +public abstract class CurrentUserControllerBase : UserOrCurrentUserControllerBase { } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/DisableTwoFactorProviderCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/DisableTwoFactorProviderCurrentUserController.cs new file mode 100644 index 0000000000..60d9ebad9d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/DisableTwoFactorProviderCurrentUserController.cs @@ -0,0 +1,40 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.User.Current; + +[ApiVersion("1.0")] +public class DisableTwoFactorProviderCurrentUserController : CurrentUserControllerBase +{ + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IUserTwoFactorLoginService _userTwoFactorLoginService; + + public DisableTwoFactorProviderCurrentUserController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUserTwoFactorLoginService userTwoFactorLoginService) + { + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _userTwoFactorLoginService = userTwoFactorLoginService; + } + + [MapToApiVersion("1.0")] + [HttpDelete("2fa/{providerName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task DisableTwoFactorProvider(string providerName, string code) + { + Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor); + + Attempt result = await _userTwoFactorLoginService.DisableByCodeAsync(providerName, userKey, code); + + return result.Success + ? Ok() + : TwoFactorOperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/EnableTwoFactorProviderCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/EnableTwoFactorProviderCurrentUserController.cs new file mode 100644 index 0000000000..034a803972 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/EnableTwoFactorProviderCurrentUserController.cs @@ -0,0 +1,42 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.User.Current; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.User.Current; + +[ApiVersion("1.0")] +public class EnableTwoFactorProviderCurrentUserController : CurrentUserControllerBase +{ + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IUserTwoFactorLoginService _userTwoFactorLoginService; + + public EnableTwoFactorProviderCurrentUserController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUserTwoFactorLoginService userTwoFactorLoginService) + { + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _userTwoFactorLoginService = userTwoFactorLoginService; + } + + [MapToApiVersion("1.0")] + [HttpPost("2fa/{providerName}")] + [ProducesResponseType(typeof(ISetupTwoFactorModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task EnableTwoFactorProvider(string providerName, EnableTwoFactorRequestModel model) + { + Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor); + + Attempt result = await _userTwoFactorLoginService.ValidateAndSaveAsync(providerName, userKey, model.Secret, model.Code); + + return result.Success + ? Ok() + : TwoFactorOperationStatusResult(result.Result); + } +} + diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetTwoFactorSetupForProviderCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetTwoFactorSetupForProviderCurrentUserController.cs new file mode 100644 index 0000000000..51932ef1b0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetTwoFactorSetupForProviderCurrentUserController.cs @@ -0,0 +1,40 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.User.Current; + +[ApiVersion("1.0")] +public class GetTwoFactorSetupForProviderCurrentUserController : CurrentUserControllerBase +{ + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IUserTwoFactorLoginService _userTwoFactorLoginService; + + public GetTwoFactorSetupForProviderCurrentUserController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUserTwoFactorLoginService userTwoFactorLoginService) + { + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _userTwoFactorLoginService = userTwoFactorLoginService; + } + + [MapToApiVersion("1.0")] + [HttpGet("2fa/{providerName}")] + [ProducesResponseType(typeof(ISetupTwoFactorModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task GetTwoFactorProviderByName(string providerName) + { + Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor); + + Attempt result = await _userTwoFactorLoginService.GetSetupInfoAsync(userKey, providerName); + + return result.Status is TwoFactorOperationStatus.Success + ? Ok(result.Result) + : TwoFactorOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/ListTwoFactorProvidersCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/ListTwoFactorProvidersCurrentUserController.cs new file mode 100644 index 0000000000..094bfd1ecb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/ListTwoFactorProvidersCurrentUserController.cs @@ -0,0 +1,39 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +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; + +namespace Umbraco.Cms.Api.Management.Controllers.User.Current; + +[ApiVersion("1.0")] +public class ListTwoFactorProvidersCurrentUserController : CurrentUserControllerBase +{ + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IUserTwoFactorLoginService _userTwoFactorLoginService; + + public ListTwoFactorProvidersCurrentUserController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUserTwoFactorLoginService userUserTwoFactorLoginService) + { + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _userTwoFactorLoginService = userUserTwoFactorLoginService; + } + + [MapToApiVersion("1.0")] + [HttpGet("2fa")] + [ProducesResponseType(typeof(IEnumerable),StatusCodes.Status200OK)] + public async Task ListTwoFactorProvidersForCurrentUser() + { + Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor); + + Attempt, TwoFactorOperationStatus> result = await _userTwoFactorLoginService.GetProviderNamesAsync(userKey); + + return result.Success + ? Ok(result.Result) + : TwoFactorOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/DisableTwoFactorProviderUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/DisableTwoFactorProviderUserController.cs new file mode 100644 index 0000000000..6ffd593aaf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/DisableTwoFactorProviderUserController.cs @@ -0,0 +1,51 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Security.Authorization.User; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.User; + +[ApiVersion("1.0")] +public class DisableTwoFactorProviderUserController : UserControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IUserTwoFactorLoginService _userTwoFactorLoginService; + + public DisableTwoFactorProviderUserController( + IAuthorizationService authorizationService, + IUserTwoFactorLoginService userTwoFactorLoginService) + { + _authorizationService = authorizationService; + _userTwoFactorLoginService = userTwoFactorLoginService; + } + + [MapToApiVersion("1.0")] + [HttpDelete("{id:guid}/2fa/{providerName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task DisableTwoFactorProvider(Guid id, string providerName) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + UserPermissionResource.WithKeys(id), + AuthorizationPolicies.AdminUserEditsRequireAdmin); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _userTwoFactorLoginService.DisableAsync(id,providerName); + + return result.Success + ? Ok() + : TwoFactorOperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ListTwoFactorProvidersUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ListTwoFactorProvidersUserController.cs new file mode 100644 index 0000000000..1963182f94 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ListTwoFactorProvidersUserController.cs @@ -0,0 +1,51 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Security.Authorization.User; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.User; + +[ApiVersion("1.0")] +public class ListTwoFactorProvidersUserController : UserControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IUserTwoFactorLoginService _userTwoFactorLoginService; + + public ListTwoFactorProvidersUserController( + IAuthorizationService authorizationService, + IUserTwoFactorLoginService userTwoFactorLoginService) + { + _authorizationService = authorizationService; + _userTwoFactorLoginService = userTwoFactorLoginService; + } + + [MapToApiVersion("1.0")] + [HttpGet("{id:guid}/2fa")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ListTwoFactorProviders(Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + UserPermissionResource.WithKeys(id), + AuthorizationPolicies.AdminUserEditsRequireAdmin); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt, TwoFactorOperationStatus> result = await _userTwoFactorLoginService.GetProviderNamesAsync(id); + + return result.Success + ? Ok(result.Result) + : TwoFactorOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/UserControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/UserControllerBase.cs index 46a66535d5..742afebbd4 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/UserControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/UserControllerBase.cs @@ -1,132 +1,12 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Routing; -using Umbraco.Cms.Core.Models.Membership; -using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.User; -[ApiController] [VersionedApiBackOfficeRoute("user")] -[ApiExplorerSettings(GroupName = "User")] [Authorize(Policy = "New" + AuthorizationPolicies.SectionAccessUsers)] -public abstract class UserControllerBase : ManagementApiControllerBase +public abstract class UserControllerBase : UserOrCurrentUserControllerBase { - protected IActionResult UserOperationStatusResult(UserOperationStatus status, ErrorMessageResult? errorMessageResult = null) => - status switch - { - UserOperationStatus.Success => Ok(), - UserOperationStatus.MissingUser => - StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() - .WithTitle("A performing user is required for the operation, but none was found") - .Build()), - UserOperationStatus.MissingUserGroup => NotFound(new ProblemDetailsBuilder() - .WithTitle("Missing User Group") - .WithDetail("The specified user group was not found.") - .Build()), - UserOperationStatus.NoUserGroup => BadRequest(new ProblemDetailsBuilder() - .WithTitle("No User Group Specified") - .WithDetail("A user group must be specified to create a user") - .Build()), - UserOperationStatus.UserNameIsNotEmail => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Invalid Username") - .WithDetail("The username must be the same as the email.") - .Build()), - UserOperationStatus.EmailCannotBeChanged => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Email Cannot be changed") - .WithDetail("Local login is disabled, so the email cannot be changed.") - .Build()), - UserOperationStatus.DuplicateUserName => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Duplicate Username") - .WithDetail("The username is already in use.") - .Build()), - UserOperationStatus.DuplicateEmail => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Duplicate Email") - .WithDetail("The email is already in use.") - .Build()), - UserOperationStatus.Unauthorized => Unauthorized(), - UserOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Cancelled by notification") - .WithDetail("A notification handler prevented the user operation.") - .Build()), - UserOperationStatus.CannotInvite => - StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() - .WithTitle("Cannot send user invitation") - .Build()), - UserOperationStatus.CannotDelete => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Cannot delete user") - .WithDetail("The user cannot be deleted.") - .Build()), - UserOperationStatus.CannotDisableSelf => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Cannot disable") - .WithDetail("A user cannot disable itself.") - .Build()), - UserOperationStatus.CannotDeleteSelf => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Cannot delete") - .WithDetail("A user cannot delete itself.") - .Build()), - UserOperationStatus.OldPasswordRequired => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Old password required") - .WithDetail("The old password is required to change the password of the specified user.") - .Build()), - UserOperationStatus.InvalidAvatar => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Invalid avatar") - .WithDetail("The selected avatar is invalid") - .Build()), - UserOperationStatus.InvalidEmail => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Invalid email") - .WithDetail("The email is invalid") - .Build()), - UserOperationStatus.AvatarFileNotFound => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Avatar file not found") - .WithDetail("The file key did not resolve in to a file") - .Build()), - UserOperationStatus.ContentStartNodeNotFound => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Content Start Node not found") - .WithDetail("Some of the provided content start nodes was not found.") - .Build()), - UserOperationStatus.MediaStartNodeNotFound => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Media Start Node not found") - .WithDetail("Some of the provided media start nodes was not found.") - .Build()), - UserOperationStatus.UserNotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("The user was not found") - .WithDetail("The specified user was not found.") - .Build()), - UserOperationStatus.CannotDisableInvitedUser => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Cannot disable invited user") - .WithDetail("An invited user cannot be disabled.") - .Build()), - UserOperationStatus.UnknownFailure => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Unknown failure") - .WithDetail(errorMessageResult?.Error?.ErrorMessage ?? "The error was unknown") - .Build()), - UserOperationStatus.InvalidIsoCode => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Invalid ISO code") - .WithDetail("The specified ISO code is invalid.") - .Build()), - UserOperationStatus.InvalidInviteToken => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Invalid verification token") - .WithDetail("The specified verification token is invalid.") - .Build()), - UserOperationStatus.MediaNodeNotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("Media node not found") - .WithDetail("The specified media node was not found.") - .Build()), - UserOperationStatus.ContentNodeNotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("Content node not found") - .WithDetail("The specified content node was not found.") - .Build()), - UserOperationStatus.NotInInviteState => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Invalid user state") - .WithDetail("The target user is not in the invite state.") - .Build()), - UserOperationStatus.Forbidden => Forbidden(), - _ => StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() - .WithTitle("Unknown user operation status.") - .Build()), - }; + } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs new file mode 100644 index 0000000000..1fa438ba38 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs @@ -0,0 +1,152 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.User; + +[ApiController] +[ApiExplorerSettings(GroupName = "User")] +public abstract class UserOrCurrentUserControllerBase : ManagementApiControllerBase +{ + protected IActionResult UserOperationStatusResult(UserOperationStatus status, ErrorMessageResult? errorMessageResult = null) => + status switch + { + UserOperationStatus.Success => Ok(), + UserOperationStatus.MissingUser => + StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() + .WithTitle("A performing user is required for the operation, but none was found") + .Build()), + UserOperationStatus.MissingUserGroup => NotFound(new ProblemDetailsBuilder() + .WithTitle("Missing User Group") + .WithDetail("The specified user group was not found.") + .Build()), + UserOperationStatus.NoUserGroup => BadRequest(new ProblemDetailsBuilder() + .WithTitle("No User Group Specified") + .WithDetail("A user group must be specified to create a user") + .Build()), + UserOperationStatus.UserNameIsNotEmail => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid Username") + .WithDetail("The username must be the same as the email.") + .Build()), + UserOperationStatus.EmailCannotBeChanged => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Email Cannot be changed") + .WithDetail("Local login is disabled, so the email cannot be changed.") + .Build()), + UserOperationStatus.DuplicateUserName => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Duplicate Username") + .WithDetail("The username is already in use.") + .Build()), + UserOperationStatus.DuplicateEmail => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Duplicate Email") + .WithDetail("The email is already in use.") + .Build()), + UserOperationStatus.Unauthorized => Unauthorized(), + UserOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cancelled by notification") + .WithDetail("A notification handler prevented the user operation.") + .Build()), + UserOperationStatus.CannotInvite => + StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() + .WithTitle("Cannot send user invitation") + .Build()), + UserOperationStatus.CannotDelete => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cannot delete user") + .WithDetail("The user cannot be deleted.") + .Build()), + UserOperationStatus.CannotDisableSelf => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cannot disable") + .WithDetail("A user cannot disable itself.") + .Build()), + UserOperationStatus.CannotDeleteSelf => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cannot delete") + .WithDetail("A user cannot delete itself.") + .Build()), + UserOperationStatus.OldPasswordRequired => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Old password required") + .WithDetail("The old password is required to change the password of the specified user.") + .Build()), + UserOperationStatus.InvalidAvatar => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid avatar") + .WithDetail("The selected avatar is invalid") + .Build()), + UserOperationStatus.InvalidEmail => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid email") + .WithDetail("The email is invalid") + .Build()), + UserOperationStatus.AvatarFileNotFound => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Avatar file not found") + .WithDetail("The file key did not resolve in to a file") + .Build()), + UserOperationStatus.ContentStartNodeNotFound => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Content Start Node not found") + .WithDetail("Some of the provided content start nodes was not found.") + .Build()), + UserOperationStatus.MediaStartNodeNotFound => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Media Start Node not found") + .WithDetail("Some of the provided media start nodes was not found.") + .Build()), + UserOperationStatus.UserNotFound => NotFound(new ProblemDetailsBuilder() + .WithTitle("The user was not found") + .WithDetail("The specified user was not found.") + .Build()), + UserOperationStatus.CannotDisableInvitedUser => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cannot disable invited user") + .WithDetail("An invited user cannot be disabled.") + .Build()), + UserOperationStatus.UnknownFailure => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Unknown failure") + .WithDetail(errorMessageResult?.Error?.ErrorMessage ?? "The error was unknown") + .Build()), + UserOperationStatus.InvalidIsoCode => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid ISO code") + .WithDetail("The specified ISO code is invalid.") + .Build()), + UserOperationStatus.InvalidInviteToken => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid verification token") + .WithDetail("The specified verification token is invalid.") + .Build()), + UserOperationStatus.MediaNodeNotFound => NotFound(new ProblemDetailsBuilder() + .WithTitle("Media node not found") + .WithDetail("The specified media node was not found.") + .Build()), + UserOperationStatus.ContentNodeNotFound => NotFound(new ProblemDetailsBuilder() + .WithTitle("Content node not found") + .WithDetail("The specified content node was not found.") + .Build()), + UserOperationStatus.NotInInviteState => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid user state") + .WithDetail("The target user is not in the invite state.") + .Build()), + UserOperationStatus.Forbidden => Forbidden(), + _ => StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() + .WithTitle("Unknown user operation status.") + .Build()), + }; + + protected IActionResult TwoFactorOperationStatusResult(TwoFactorOperationStatus status) => + status switch + { + TwoFactorOperationStatus.Success => Ok(), + TwoFactorOperationStatus.ProviderNameNotFound => NotFound(new ProblemDetailsBuilder() + .WithTitle("Missing 2FA provider") + .WithDetail("The specified 2fa provider was not found.") + .Build()), + TwoFactorOperationStatus.ProviderAlreadySetup => BadRequest(new ProblemDetailsBuilder() + .WithTitle("2FA provider already configured") + .WithDetail("The current user already have the provider configured.") + .Build()), + TwoFactorOperationStatus.InvalidCode => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid code") + .WithDetail("The specified 2fa code was invalid in combination with the provider.") + .Build()), + TwoFactorOperationStatus.UserNotFound => NotFound(new ProblemDetailsBuilder() + .WithTitle("User not found") + .WithDetail("The specified user id was not found.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() + .WithTitle("Unknown two factor operation status.") + .Build()), + }; +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs index bc642d7163..5ed72e92b5 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs @@ -42,6 +42,24 @@ public static class BackOfficeAuthBuilderExtensions { options.LoginPath = "/umbraco/login"; options.Cookie.Name = Constants.Security.NewBackOfficeAuthenticationType; + }) + .AddCookie(Constants.Security.NewBackOfficeExternalAuthenticationType, options => + { + options.Cookie.Name = Constants.Security.NewBackOfficeExternalAuthenticationType; + options.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }) + + // Although we don't natively support this, we add it anyways so that if end-users implement the required logic + // they don't have to worry about manually adding this scheme or modifying the sign in manager + .AddCookie(Constants.Security.NewBackOfficeTwoFactorAuthenticationType, options => + { + options.Cookie.Name = Constants.Security.NewBackOfficeTwoFactorAuthenticationType; + options.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }) + .AddCookie(Constants.Security.NewBackOfficeTwoFactorRememberMeAuthenticationType, options => + { + options.Cookie.Name = Constants.Security.NewBackOfficeTwoFactorRememberMeAuthenticationType; + options.ExpireTimeSpan = TimeSpan.FromMinutes(5); }); return builder; diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index f883775b00..30817cf55a 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -19665,6 +19665,162 @@ ] } }, + "/umbraco/management/api/v1/user/{id}/2fa": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserById2fa", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserTwoFactorProviderModel" + } + ] + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserTwoFactorProviderModel" + } + ] + } + } + }, + "text/plain": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserTwoFactorProviderModel" + } + ] + } + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/{id}/2fa/{providerName}": { + "delete": { + "tags": [ + "User" + ], + "operationId": "DeleteUserById2faByProviderName", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "providerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/user/avatar/{id}": { "delete": { "tags": [ @@ -19950,6 +20106,350 @@ ] } }, + "/umbraco/management/api/v1/user/current/2fa": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrent2fa", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserTwoFactorProviderModel" + } + ] + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserTwoFactorProviderModel" + } + ] + } + } + }, + "text/plain": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserTwoFactorProviderModel" + } + ] + } + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/current/2fa/{providerName}": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserCurrent2faByProviderName", + "parameters": [ + { + "name": "providerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EnableTwoFactorRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EnableTwoFactorRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EnableTwoFactorRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/NoopSetupTwoFactorModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/NoopSetupTwoFactorModel" + } + ] + } + }, + "text/plain": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/NoopSetupTwoFactorModel" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + }, + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrent2faByProviderName", + "parameters": [ + { + "name": "providerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/NoopSetupTwoFactorModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/NoopSetupTwoFactorModel" + } + ] + } + }, + "text/plain": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/NoopSetupTwoFactorModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + }, + "delete": { + "tags": [ + "User" + ], + "operationId": "DeleteUserCurrent2faByProviderName", + "parameters": [ + { + "name": "providerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/user/current/avatar": { "post": { "tags": [ @@ -23373,6 +23873,22 @@ }, "additionalProperties": false }, + "EnableTwoFactorRequestModel": { + "required": [ + "code", + "secret" + ], + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "secret": { + "type": "string" + } + }, + "additionalProperties": false + }, "EnableUserRequestModel": { "required": [ "userIds" @@ -24594,6 +25110,10 @@ }, "additionalProperties": false }, + "NoopSetupTwoFactorModel": { + "type": "object", + "additionalProperties": false + }, "ObjectTypeResponseModel": { "required": [ "id" @@ -28144,6 +28664,22 @@ }, "additionalProperties": false }, + "UserTwoFactorProviderModel": { + "required": [ + "isEnabledOnUser", + "providerName" + ], + "type": "object", + "properties": { + "providerName": { + "type": "string" + }, + "isEnabledOnUser": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "ValueModelBaseModel": { "required": [ "alias" diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionAuthorizer.cs index f6c49b8963..8e4a2ff5a8 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionAuthorizer.cs @@ -18,7 +18,7 @@ internal sealed class ContentPermissionAuthorizer : IContentPermissionAuthorizer } /// - public async Task IsAuthorizedAsync(IPrincipal currentUser, IEnumerable contentKeys, ISet permissionsToCheck) + public async Task IsDeniedAsync(IPrincipal currentUser, IEnumerable contentKeys, ISet permissionsToCheck) { if (!contentKeys.Any()) { @@ -30,43 +30,50 @@ internal sealed class ContentPermissionAuthorizer : IContentPermissionAuthorizer var result = await _contentPermissionService.AuthorizeAccessAsync(user, contentKeys, permissionsToCheck); - return result == ContentAuthorizationStatus.Success; + // If we can't find the content item(s) then we can't determine whether you are denied access. + return result is not (ContentAuthorizationStatus.Success or ContentAuthorizationStatus.NotFound); } /// - public async Task IsAuthorizedWithDescendantsAsync(IPrincipal currentUser, Guid parentKey, ISet permissionsToCheck) + public async Task IsDeniedWithDescendantsAsync(IPrincipal currentUser, Guid parentKey, ISet permissionsToCheck) { IUser user = _authorizationHelper.GetUmbracoUser(currentUser); var result = await _contentPermissionService.AuthorizeDescendantsAccessAsync(user, parentKey, permissionsToCheck); - return result == ContentAuthorizationStatus.Success; + // If we can't find the content item(s) then we can't determine whether you are denied access. + return result is not (ContentAuthorizationStatus.Success or ContentAuthorizationStatus.NotFound); } /// - public async Task IsAuthorizedAtRootLevelAsync(IPrincipal currentUser, ISet permissionsToCheck) + public async Task IsDeniedAtRootLevelAsync(IPrincipal currentUser, ISet permissionsToCheck) { IUser user = _authorizationHelper.GetUmbracoUser(currentUser); var result = await _contentPermissionService.AuthorizeRootAccessAsync(user, permissionsToCheck); - return result == ContentAuthorizationStatus.Success; + // If we can't find the content item(s) then we can't determine whether you are denied access. + return result is not (ContentAuthorizationStatus.Success or ContentAuthorizationStatus.NotFound); } /// - public async Task IsAuthorizedAtRecycleBinLevelAsync(IPrincipal currentUser, ISet permissionsToCheck) + public async Task IsDeniedAtRecycleBinLevelAsync(IPrincipal currentUser, ISet permissionsToCheck) { IUser user = _authorizationHelper.GetUmbracoUser(currentUser); var result = await _contentPermissionService.AuthorizeBinAccessAsync(user, permissionsToCheck); - return result == ContentAuthorizationStatus.Success; + // If we can't find the content item(s) then we can't determine whether you are denied access. + return result is not (ContentAuthorizationStatus.Success or ContentAuthorizationStatus.NotFound); } - public async Task IsAuthorizedForCultures(IPrincipal currentUser, ISet culturesToCheck) + public async Task IsDeniedForCultures(IPrincipal currentUser, ISet culturesToCheck) { IUser user = _authorizationHelper.GetUmbracoUser(currentUser); + ContentAuthorizationStatus result = await _contentPermissionService.AuthorizeCultureAccessAsync(user, culturesToCheck); - return result is ContentAuthorizationStatus.Success; + + // If we can't find the content item(s) then we can't determine whether you are denied access. + return result is not (ContentAuthorizationStatus.Success or ContentAuthorizationStatus.NotFound); } } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionHandler.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionHandler.cs index 7a46fa2557..b5692769d3 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionHandler.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionHandler.cs @@ -22,36 +22,33 @@ public class ContentPermissionHandler : MustSatisfyRequirementAuthorizationHandl ContentPermissionRequirement requirement, ContentPermissionResource resource) { - if (resource.CheckRoot - && await _contentPermissionAuthorizer.IsAuthorizedAtRootLevelAsync(context.User, resource.PermissionsToCheck) is false) + var result = true; + + if (resource.CheckRoot) { - return false; + result &= await _contentPermissionAuthorizer.IsDeniedAtRootLevelAsync(context.User, resource.PermissionsToCheck) is false; } - if (resource.CheckRecycleBin - && await _contentPermissionAuthorizer.IsAuthorizedAtRecycleBinLevelAsync(context.User, resource.PermissionsToCheck) is false) + if (resource.CheckRecycleBin) { - return false; + result &= await _contentPermissionAuthorizer.IsDeniedAtRecycleBinLevelAsync(context.User, resource.PermissionsToCheck) is false; } - if (resource.ParentKeyForBranch is not null - && await _contentPermissionAuthorizer.IsAuthorizedWithDescendantsAsync(context.User, resource.ParentKeyForBranch.Value, resource.PermissionsToCheck) is false) + if (resource.ParentKeyForBranch is not null) { - return false; + result &= await _contentPermissionAuthorizer.IsDeniedWithDescendantsAsync(context.User, resource.ParentKeyForBranch.Value, resource.PermissionsToCheck) is false; } - if (resource.ContentKeys.Any() - && await _contentPermissionAuthorizer.IsAuthorizedAsync(context.User, resource.ContentKeys, resource.PermissionsToCheck) is false) + if (resource.ContentKeys.Any()) { - return false; - } - - if (resource.CulturesToCheck is not null - && await _contentPermissionAuthorizer.IsAuthorizedForCultures(context.User, resource.CulturesToCheck) is false) - { - return false; + result &= await _contentPermissionAuthorizer.IsDeniedAsync(context.User, resource.ContentKeys, resource.PermissionsToCheck) is false; } - return true; + if (resource.CulturesToCheck is not null) + { + result &= await _contentPermissionAuthorizer.IsDeniedForCultures(context.User, resource.CulturesToCheck) is false; + } + + return result; } } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/IContentPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/IContentPermissionAuthorizer.cs index 5257b25e3e..bb9a5d7782 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/IContentPermissionAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/IContentPermissionAuthorizer.cs @@ -16,7 +16,7 @@ public interface IContentPermissionAuthorizer /// The permission to authorize. /// Returns true if authorization is successful, otherwise false. Task IsAuthorizedAsync(IPrincipal currentUser, Guid contentKey, char permissionToCheck) - => IsAuthorizedAsync(currentUser, contentKey.Yield(), new HashSet { permissionToCheck }); + => IsDeniedAsync(currentUser, contentKey.Yield(), new HashSet { permissionToCheck }); /// /// Authorizes whether the current user has access to the specified content item(s). @@ -25,7 +25,7 @@ public interface IContentPermissionAuthorizer /// The keys of the content items to check for. /// The collection of permissions to authorize. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAsync(IPrincipal currentUser, IEnumerable contentKeys, ISet permissionsToCheck); + Task IsDeniedAsync(IPrincipal currentUser, IEnumerable contentKeys, ISet permissionsToCheck); /// /// Authorizes whether the current user has access to the descendants of the specified content item. @@ -35,7 +35,7 @@ public interface IContentPermissionAuthorizer /// The permission to authorize. /// Returns true if authorization is successful, otherwise false. Task IsAuthorizedWithDescendantsAsync(IPrincipal currentUser, Guid parentKey, char permissionToCheck) - => IsAuthorizedWithDescendantsAsync(currentUser, parentKey, new HashSet { permissionToCheck }); + => IsDeniedWithDescendantsAsync(currentUser, parentKey, new HashSet { permissionToCheck }); /// /// Authorizes whether the current user has access to the descendants of the specified content item. @@ -44,7 +44,7 @@ public interface IContentPermissionAuthorizer /// The key of the parent content item. /// The collection of permissions to authorize. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedWithDescendantsAsync(IPrincipal currentUser, Guid parentKey, ISet permissionsToCheck); + Task IsDeniedWithDescendantsAsync(IPrincipal currentUser, Guid parentKey, ISet permissionsToCheck); /// /// Authorizes whether the current user has access to the root item. @@ -53,7 +53,7 @@ public interface IContentPermissionAuthorizer /// The permission to authorize. /// Returns true if authorization is successful, otherwise false. Task IsAuthorizedAtRootLevelAsync(IPrincipal currentUser, char permissionToCheck) - => IsAuthorizedAtRootLevelAsync(currentUser, new HashSet { permissionToCheck }); + => IsDeniedAtRootLevelAsync(currentUser, new HashSet { permissionToCheck }); /// /// Authorizes whether the current user has access to the root item. @@ -61,7 +61,7 @@ public interface IContentPermissionAuthorizer /// The current user's principal. /// The collection of permissions to authorize. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAtRootLevelAsync(IPrincipal currentUser, ISet permissionsToCheck); + Task IsDeniedAtRootLevelAsync(IPrincipal currentUser, ISet permissionsToCheck); /// /// Authorizes whether the current user has access to the recycle bin item. @@ -70,7 +70,7 @@ public interface IContentPermissionAuthorizer /// The permission to authorize. /// Returns true if authorization is successful, otherwise false. Task IsAuthorizedAtRecycleBinLevelAsync(IPrincipal currentUser, char permissionToCheck) - => IsAuthorizedAtRecycleBinLevelAsync(currentUser, new HashSet { permissionToCheck }); + => IsDeniedAtRecycleBinLevelAsync(currentUser, new HashSet { permissionToCheck }); /// /// Authorizes whether the current user has access to the recycle bin item. @@ -78,7 +78,7 @@ public interface IContentPermissionAuthorizer /// The current user's principal. /// The collection of permissions to authorize. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAtRecycleBinLevelAsync(IPrincipal currentUser, ISet permissionsToCheck); + Task IsDeniedAtRecycleBinLevelAsync(IPrincipal currentUser, ISet permissionsToCheck); - Task IsAuthorizedForCultures(IPrincipal currentUser, ISet culturesToCheck); + Task IsDeniedForCultures(IPrincipal currentUser, ISet culturesToCheck); } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/FeatureAuthorizeHandler.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/FeatureAuthorizeHandler.cs index b66f439802..6ba7903631 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/FeatureAuthorizeHandler.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/FeatureAuthorizeHandler.cs @@ -18,5 +18,5 @@ public class FeatureAuthorizeHandler : MustSatisfyRequirementAuthorizationHandle /// protected override async Task IsAuthorized(AuthorizationHandlerContext context, FeatureAuthorizeRequirement requirement) - => await _featureAuthorizer.IsAuthorizedAsync(context); + => await _featureAuthorizer.IsDeniedAsync(context) is false; } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/FeatureAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/FeatureAuthorizer.cs index 25af129346..a94ec9f220 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/FeatureAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/FeatureAuthorizer.cs @@ -22,13 +22,13 @@ internal sealed class FeatureAuthorizer : IFeatureAuthorizer } /// - public async Task IsAuthorizedAsync(AuthorizationHandlerContext context) + public async Task IsDeniedAsync(AuthorizationHandlerContext context) { Endpoint? endpoint = null; if (_runtimeState.Level != RuntimeLevel.Run && _runtimeState.Level != RuntimeLevel.Upgrade) { - return false; + return true; } switch (context.Resource) @@ -62,6 +62,6 @@ internal sealed class FeatureAuthorizer : IFeatureAuthorizer ControllerActionDescriptor? actionDescriptor = endpoint.Metadata.GetMetadata(); Type? controllerType = actionDescriptor?.ControllerTypeInfo.AsType(); - return await Task.FromResult(_umbracoFeatures.IsControllerEnabled(controllerType)); + return await Task.FromResult(_umbracoFeatures.IsControllerEnabled(controllerType) is false); } } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/IFeatureAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/IFeatureAuthorizer.cs index febfb77054..69cf7513d1 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/IFeatureAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Feature/IFeatureAuthorizer.cs @@ -12,5 +12,5 @@ public interface IFeatureAuthorizer /// /// The authorization context. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAsync(AuthorizationHandlerContext context); + Task IsDeniedAsync(AuthorizationHandlerContext context); } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/IMediaPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/IMediaPermissionAuthorizer.cs index 056b13419e..1611a1cf4a 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/IMediaPermissionAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/IMediaPermissionAuthorizer.cs @@ -15,7 +15,7 @@ public interface IMediaPermissionAuthorizer /// The key of the media item to check for. /// Returns true if authorization is successful, otherwise false. Task IsAuthorizedAsync(IPrincipal currentUser, Guid mediaKey) - => IsAuthorizedAsync(currentUser, mediaKey.Yield()); + => IsDeniedAsync(currentUser, mediaKey.Yield()); /// /// Authorizes whether the current user has access to the specified media item(s). @@ -23,19 +23,19 @@ public interface IMediaPermissionAuthorizer /// The current user's principal. /// The keys of the media items to check for. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAsync(IPrincipal currentUser, IEnumerable mediaKeys); + Task IsDeniedAsync(IPrincipal currentUser, IEnumerable mediaKeys); /// /// Authorizes whether the current user has access to the root item. /// /// The current user's principal. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAtRootLevelAsync(IPrincipal currentUser); + Task IsDeniedAtRootLevelAsync(IPrincipal currentUser); /// /// Authorizes whether the current user has access to the recycle bin item. /// /// The current user's principal. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAtRecycleBinLevelAsync(IPrincipal currentUser); + Task IsDeniedAtRecycleBinLevelAsync(IPrincipal currentUser); } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/MediaPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/MediaPermissionAuthorizer.cs index e538b19d2e..3bb5172cec 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/MediaPermissionAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/MediaPermissionAuthorizer.cs @@ -18,7 +18,7 @@ internal sealed class MediaPermissionAuthorizer : IMediaPermissionAuthorizer } /// - public async Task IsAuthorizedAsync(IPrincipal currentUser, IEnumerable mediaKeys) + public async Task IsDeniedAsync(IPrincipal currentUser, IEnumerable mediaKeys) { if (!mediaKeys.Any()) { @@ -30,26 +30,29 @@ internal sealed class MediaPermissionAuthorizer : IMediaPermissionAuthorizer var result = await _mediaPermissionService.AuthorizeAccessAsync(user, mediaKeys); - return result == MediaAuthorizationStatus.Success; + // If we can't find the media item(s) then we can't determine whether you are denied access. + return result is not (MediaAuthorizationStatus.Success or MediaAuthorizationStatus.NotFound); } /// - public async Task IsAuthorizedAtRootLevelAsync(IPrincipal currentUser) + public async Task IsDeniedAtRootLevelAsync(IPrincipal currentUser) { IUser user = _authorizationHelper.GetUmbracoUser(currentUser); var result = await _mediaPermissionService.AuthorizeRootAccessAsync(user); - return result == MediaAuthorizationStatus.Success; + // If we can't find the media item(s) then we can't determine whether you are denied access. + return result is not (MediaAuthorizationStatus.Success or MediaAuthorizationStatus.NotFound); } /// - public async Task IsAuthorizedAtRecycleBinLevelAsync(IPrincipal currentUser) + public async Task IsDeniedAtRecycleBinLevelAsync(IPrincipal currentUser) { IUser user = _authorizationHelper.GetUmbracoUser(currentUser); var result = await _mediaPermissionService.AuthorizeBinAccessAsync(user); - return result == MediaAuthorizationStatus.Success; + // If we can't find the media item(s) then we can't determine whether you are denied access. + return result is not (MediaAuthorizationStatus.Success or MediaAuthorizationStatus.NotFound); } } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/MediaPermissionHandler.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/MediaPermissionHandler.cs index e383fddc99..d680669203 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/MediaPermissionHandler.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Media/MediaPermissionHandler.cs @@ -26,17 +26,17 @@ public class MediaPermissionHandler : MustSatisfyRequirementAuthorizationHandler if (resource.CheckRoot) { - result &= await _mediaPermissionAuthorizer.IsAuthorizedAtRootLevelAsync(context.User); + result &= await _mediaPermissionAuthorizer.IsDeniedAtRootLevelAsync(context.User) is false; } if (resource.CheckRecycleBin) { - result &= await _mediaPermissionAuthorizer.IsAuthorizedAtRecycleBinLevelAsync(context.User); + result &= await _mediaPermissionAuthorizer.IsDeniedAtRecycleBinLevelAsync(context.User) is false; } if (resource.MediaKeys.Any()) { - result &= await _mediaPermissionAuthorizer.IsAuthorizedAsync(context.User, resource.MediaKeys); + result &= await _mediaPermissionAuthorizer.IsDeniedAsync(context.User, resource.MediaKeys) is false; } return result; diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/User/IUserPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/User/IUserPermissionAuthorizer.cs index e126f306c8..f923cf9670 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/User/IUserPermissionAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/User/IUserPermissionAuthorizer.cs @@ -14,8 +14,8 @@ public interface IUserPermissionAuthorizer /// The current user's principal. /// The key of the user to check for. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAsync(IPrincipal currentUser, Guid userKey) - => IsAuthorizedAsync(currentUser, userKey.Yield()); + Task IsDeniedAsync(IPrincipal currentUser, Guid userKey) + => IsDeniedAsync(currentUser, userKey.Yield()); /// /// Authorizes whether the current user has access to the specified user account(s). @@ -23,5 +23,5 @@ public interface IUserPermissionAuthorizer /// The current user's principal. /// The keys of the users to check for. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAsync(IPrincipal currentUser, IEnumerable userKeys); + Task IsDeniedAsync(IPrincipal currentUser, IEnumerable userKeys); } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/User/UserPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/User/UserPermissionAuthorizer.cs index f304f4effd..eca8cef4c0 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/User/UserPermissionAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/User/UserPermissionAuthorizer.cs @@ -18,18 +18,18 @@ internal sealed class UserPermissionAuthorizer : IUserPermissionAuthorizer } /// - public async Task IsAuthorizedAsync(IPrincipal currentUser, IEnumerable userKeys) + public async Task IsDeniedAsync(IPrincipal currentUser, IEnumerable userKeys) { if (!userKeys.Any()) { - // Must succeed this requirement since we cannot process it. - return true; + // We can't denied no keys. + return false; } IUser performingUser = _authorizationHelper.GetUmbracoUser(currentUser); - var result = await _userPermissionService.AuthorizeAccessAsync(performingUser, userKeys); + UserAuthorizationStatus result = await _userPermissionService.AuthorizeAccessAsync(performingUser, userKeys); - return result == UserAuthorizationStatus.Success; + return result is not UserAuthorizationStatus.Success; } } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/User/UserPermissionHandler.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/User/UserPermissionHandler.cs index 7330eb59b7..777164784c 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/User/UserPermissionHandler.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/User/UserPermissionHandler.cs @@ -21,5 +21,5 @@ public class UserPermissionHandler : MustSatisfyRequirementAuthorizationHandler< AuthorizationHandlerContext context, UserPermissionRequirement requirement, UserPermissionResource resource) => - await _userPermissionAuthorizer.IsAuthorizedAsync(context.User, resource.UserKeys); + await _userPermissionAuthorizer.IsDeniedAsync(context.User, resource.UserKeys) is false; } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/IUserGroupPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/IUserGroupPermissionAuthorizer.cs index 47b7d4c570..f8f5760276 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/IUserGroupPermissionAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/IUserGroupPermissionAuthorizer.cs @@ -14,8 +14,8 @@ public interface IUserGroupPermissionAuthorizer /// The current user's principal. /// The key of the user group to check against. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAsync(IPrincipal currentUser, Guid userGroupKey) - => IsAuthorizedAsync(currentUser, userGroupKey.Yield()); + Task IsDeniedAsync(IPrincipal currentUser, Guid userGroupKey) + => IsDeniedAsync(currentUser, userGroupKey.Yield()); /// /// Authorizes whether the current user has access to the specified user group(s). @@ -23,5 +23,5 @@ public interface IUserGroupPermissionAuthorizer /// The current user's principal. /// The keys of the user groups to check against. /// Returns true if authorization is successful, otherwise false. - Task IsAuthorizedAsync(IPrincipal currentUser, IEnumerable userGroupKeys); + Task IsDeniedAsync(IPrincipal currentUser, IEnumerable userGroupKeys); } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/UserGroupPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/UserGroupPermissionAuthorizer.cs index eaa9b97b0f..bd239952e1 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/UserGroupPermissionAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/UserGroupPermissionAuthorizer.cs @@ -18,18 +18,18 @@ internal sealed class UserGroupPermissionAuthorizer : IUserGroupPermissionAuthor } /// - public async Task IsAuthorizedAsync(IPrincipal currentUser, IEnumerable userGroupKeys) + public async Task IsDeniedAsync(IPrincipal currentUser, IEnumerable userGroupKeys) { if (!userGroupKeys.Any()) { - // Must succeed this requirement since we cannot process it. - return true; + // We can't deny something that is not defined + return false; } IUser user = _authorizationHelper.GetUmbracoUser(currentUser); - var result = await _userGroupPermissionService.AuthorizeAccessAsync(user, userGroupKeys); + UserGroupAuthorizationStatus result = await _userGroupPermissionService.AuthorizeAccessAsync(user, userGroupKeys); - return result == UserGroupAuthorizationStatus.Success; + return result is not UserGroupAuthorizationStatus.Success; } } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/UserGroupPermissionHandler.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/UserGroupPermissionHandler.cs index a53d930f82..edad9d13a2 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/UserGroupPermissionHandler.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/UserGroup/UserGroupPermissionHandler.cs @@ -21,5 +21,5 @@ public class UserGroupPermissionHandler : MustSatisfyRequirementAuthorizationHan AuthorizationHandlerContext context, UserGroupPermissionRequirement requirement, UserGroupPermissionResource resource) => - await _userGroupPermissionAuthorizer.IsAuthorizedAsync(context.User, resource.UserGroupKeys); + await _userGroupPermissionAuthorizer.IsDeniedAsync(context.User, resource.UserGroupKeys) is false; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/EnableTwoFactorRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/EnableTwoFactorRequestModel.cs new file mode 100644 index 0000000000..faf8fa5571 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/EnableTwoFactorRequestModel.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.User.Current; + +public class EnableTwoFactorRequestModel +{ + public required string Code { get; set; } + + public required string Secret { get; set; } + +} diff --git a/src/Umbraco.Core/Constants-Security.cs b/src/Umbraco.Core/Constants-Security.cs index 156250c21c..b406b52731 100644 --- a/src/Umbraco.Core/Constants-Security.cs +++ b/src/Umbraco.Core/Constants-Security.cs @@ -79,7 +79,9 @@ public static partial class Constants public const string BackOfficeTwoFactorRememberMeAuthenticationType = "UmbracoTwoFactorRememberMeCookie"; // FIXME: remove this in favor of BackOfficeAuthenticationType when the old backoffice auth is no longer necessary public const string NewBackOfficeAuthenticationType = "NewUmbracoBackOffice"; - + public const string NewBackOfficeExternalAuthenticationType = "NewUmbracoExternalCookie"; + public const string NewBackOfficeTwoFactorAuthenticationType = "NewUmbracoTwoFactorCookie"; + public const string NewBackOfficeTwoFactorRememberMeAuthenticationType = "NewUmbracoTwoFactorRememberMeCookie"; public const string EmptyPasswordPrefix = "___UIDEMPTYPWORD__"; public const string DefaultMemberTypeAlias = "Member"; diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 5137ab6471..33770fec67 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -371,6 +371,11 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + + //Two factor providers + Services.AddUnique(); + Services.AddUnique(); + } } } diff --git a/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs b/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs index 5d33108a0f..7268012790 100644 --- a/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs +++ b/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs @@ -5,12 +5,22 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Security.Claims; using System.Security.Principal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Security; namespace Umbraco.Extensions; public static class ClaimsIdentityExtensions { + private static string? _authenticationType; + private static string AuthenticationType => + _authenticationType ??= StaticServiceProvider.Instance?.GetService>()? + .Value? + .AuthenticationType ?? Constants.Security.BackOfficeAuthenticationType; + /// /// Returns the required claim types for a back office identity /// @@ -172,9 +182,9 @@ public static class ClaimsIdentityExtensions } } - verifiedIdentity = identity.AuthenticationType == Constants.Security.BackOfficeAuthenticationType + verifiedIdentity = identity.AuthenticationType == AuthenticationType ? identity - : new ClaimsIdentity(identity.Claims, Constants.Security.BackOfficeAuthenticationType); + : new ClaimsIdentity(identity.Claims, AuthenticationType); return true; } @@ -200,8 +210,8 @@ public static class ClaimsIdentityExtensions ClaimTypes.NameIdentifier, userId, ClaimValueTypes.String, - Constants.Security.BackOfficeAuthenticationType, - Constants.Security.BackOfficeAuthenticationType, + AuthenticationType, + AuthenticationType, identity)); } @@ -211,8 +221,8 @@ public static class ClaimsIdentityExtensions ClaimTypes.Name, username, ClaimValueTypes.String, - Constants.Security.BackOfficeAuthenticationType, - Constants.Security.BackOfficeAuthenticationType, + AuthenticationType, + AuthenticationType, identity)); } @@ -222,8 +232,8 @@ public static class ClaimsIdentityExtensions ClaimTypes.GivenName, realName, ClaimValueTypes.String, - Constants.Security.BackOfficeAuthenticationType, - Constants.Security.BackOfficeAuthenticationType, + AuthenticationType, + AuthenticationType, identity)); } @@ -236,8 +246,8 @@ public static class ClaimsIdentityExtensions Constants.Security.StartContentNodeIdClaimType, startContentNode.ToInvariantString(), ClaimValueTypes.Integer32, - Constants.Security.BackOfficeAuthenticationType, - Constants.Security.BackOfficeAuthenticationType, + AuthenticationType, + AuthenticationType, identity)); } } @@ -251,8 +261,8 @@ public static class ClaimsIdentityExtensions Constants.Security.StartMediaNodeIdClaimType, startMediaNode.ToInvariantString(), ClaimValueTypes.Integer32, - Constants.Security.BackOfficeAuthenticationType, - Constants.Security.BackOfficeAuthenticationType, + AuthenticationType, + AuthenticationType, identity)); } } @@ -263,8 +273,8 @@ public static class ClaimsIdentityExtensions ClaimTypes.Locality, culture, ClaimValueTypes.String, - Constants.Security.BackOfficeAuthenticationType, - Constants.Security.BackOfficeAuthenticationType, + AuthenticationType, + AuthenticationType, identity)); } @@ -275,8 +285,8 @@ public static class ClaimsIdentityExtensions Constants.Security.SecurityStampClaimType, securityStamp, ClaimValueTypes.String, - Constants.Security.BackOfficeAuthenticationType, - Constants.Security.BackOfficeAuthenticationType, + AuthenticationType, + AuthenticationType, identity)); } @@ -289,8 +299,8 @@ public static class ClaimsIdentityExtensions Constants.Security.AllowedApplicationsClaimType, application, ClaimValueTypes.String, - Constants.Security.BackOfficeAuthenticationType, - Constants.Security.BackOfficeAuthenticationType, + AuthenticationType, + AuthenticationType, identity)); } } @@ -306,13 +316,15 @@ public static class ClaimsIdentityExtensions identity.RoleClaimType, roleName, ClaimValueTypes.String, - Constants.Security.BackOfficeAuthenticationType, - Constants.Security.BackOfficeAuthenticationType, + AuthenticationType, + AuthenticationType, identity)); } } } + + /// /// Get the start content nodes from a ClaimsIdentity /// diff --git a/src/Umbraco.Core/Models/SendCodeViewModel.cs b/src/Umbraco.Core/Models/Verify2FACodeModel.cs similarity index 73% rename from src/Umbraco.Core/Models/SendCodeViewModel.cs rename to src/Umbraco.Core/Models/Verify2FACodeModel.cs index 29d318f8ff..3d50638f27 100644 --- a/src/Umbraco.Core/Models/SendCodeViewModel.cs +++ b/src/Umbraco.Core/Models/Verify2FACodeModel.cs @@ -6,27 +6,22 @@ namespace Umbraco.Cms.Core.Models; /// /// Used for 2FA verification /// -[DataContract(Name = "code", Namespace = "")] public class Verify2FACodeModel { [Required] - [DataMember(Name = "code", IsRequired = true)] public required string Code { get; set; } [Required] - [DataMember(Name = "provider", IsRequired = true)] public required string Provider { get; set; } /// /// Flag indicating whether the sign-in cookie should persist after the browser is closed. /// - [DataMember(Name = "isPersistent", IsRequired = true)] public bool IsPersistent { get; set; } /// /// Flag indicating whether the current browser should be remember, suppressing all further two factor authentication /// prompts. /// - [DataMember(Name = "rememberClient", IsRequired = true)] public bool RememberClient { get; set; } } diff --git a/src/Umbraco.Core/Security/BackOfficeAuthenticationTypeSettings.cs b/src/Umbraco.Core/Security/BackOfficeAuthenticationTypeSettings.cs new file mode 100644 index 0000000000..c0b1112ef3 --- /dev/null +++ b/src/Umbraco.Core/Security/BackOfficeAuthenticationTypeSettings.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Core.Security; + +public class BackOfficeAuthenticationTypeSettings +{ + public string AuthenticationType { get; set; } = Constants.Security.NewBackOfficeAuthenticationType; + public string ExternalAuthenticationType { get; set; } = Constants.Security.NewBackOfficeExternalAuthenticationType; + public string TwoFactorAuthenticationType { get; set; } = Constants.Security.NewBackOfficeTwoFactorAuthenticationType; + public string TwoFactorRememberMeAuthenticationType { get; set; } = Constants.Security.NewBackOfficeTwoFactorRememberMeAuthenticationType; +} diff --git a/src/Umbraco.Core/Security/ClaimsPrincipalExtensions.cs b/src/Umbraco.Core/Security/ClaimsPrincipalExtensions.cs index 4cb9e20dac..96667b4f44 100644 --- a/src/Umbraco.Core/Security/ClaimsPrincipalExtensions.cs +++ b/src/Umbraco.Core/Security/ClaimsPrincipalExtensions.cs @@ -4,12 +4,22 @@ using System.Globalization; using System.Security.Claims; using System.Security.Principal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Security; namespace Umbraco.Extensions; public static class ClaimsPrincipalExtensions { + private static string? _authenticationType; + private static string AuthenticationType => + _authenticationType ??= StaticServiceProvider.Instance?.GetService>()? + .Value? + .AuthenticationType ?? Constants.Security.BackOfficeAuthenticationType; + public static bool IsBackOfficeAuthenticationType(this ClaimsIdentity? claimsIdentity) { if (claimsIdentity is null) @@ -17,8 +27,7 @@ public static class ClaimsPrincipalExtensions return false; } - return claimsIdentity.IsAuthenticated && - claimsIdentity.AuthenticationType == Constants.Security.BackOfficeAuthenticationType; + return claimsIdentity.IsAuthenticated && claimsIdentity.AuthenticationType == AuthenticationType; } /// diff --git a/src/Umbraco.Core/Security/ISetupTwoFactorModel.cs b/src/Umbraco.Core/Security/ISetupTwoFactorModel.cs new file mode 100644 index 0000000000..6c1b63a3d9 --- /dev/null +++ b/src/Umbraco.Core/Security/ISetupTwoFactorModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Security; + +/// +/// Marker interface that is used to setup different two factor methods. The interface ensures the openapi docs will show all possible implementations. +/// +public interface ISetupTwoFactorModel +{ +} diff --git a/src/Umbraco.Core/Security/ITwoFactorProvider.cs b/src/Umbraco.Core/Security/ITwoFactorProvider.cs index 8d2b12b6f8..21c9ae7bc6 100644 --- a/src/Umbraco.Core/Security/ITwoFactorProvider.cs +++ b/src/Umbraco.Core/Security/ITwoFactorProvider.cs @@ -1,14 +1,28 @@ namespace Umbraco.Cms.Core.Security; +/// +/// A two factor provider +/// public interface ITwoFactorProvider { + /// + /// A unique name for this provider. + /// string ProviderName { get; } - Task GetSetupDataAsync(Guid userOrMemberKey, string secret); + /// + /// Gets the data needed to setup this provider. Using the marker interface . + /// + Task GetSetupDataAsync(Guid userOrMemberKey, string secret); + /// + /// Validates the 2FA login token for the user identified by the supplied secret. + /// + // ReSharper disable once InconsistentNaming bool ValidateTwoFactorPIN(string secret, string token); /// + /// Validates the 2FA setup token for the user identified by the supplied secret. /// /// Called to confirm the setup of two factor on the user. bool ValidateTwoFactorSetup(string secret, string token); diff --git a/src/Umbraco.Core/Security/NoopSetupTwoFactorModel.cs b/src/Umbraco.Core/Security/NoopSetupTwoFactorModel.cs new file mode 100644 index 0000000000..820862eb9c --- /dev/null +++ b/src/Umbraco.Core/Security/NoopSetupTwoFactorModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Security; + +/// +/// A No-operation implementation of the ISetupTwoFactorModel. +/// +public class NoopSetupTwoFactorModel : ISetupTwoFactorModel +{ +} diff --git a/src/Umbraco.Core/Services/ITwoFactorLoginService.cs b/src/Umbraco.Core/Services/ITwoFactorLoginService.cs index 0a0cc751d5..5b10221578 100644 --- a/src/Umbraco.Core/Services/ITwoFactorLoginService.cs +++ b/src/Umbraco.Core/Services/ITwoFactorLoginService.cs @@ -29,6 +29,7 @@ public interface ITwoFactorLoginService : IService /// The returned type can be anything depending on the setup providers. You will need to cast it to the type handled by /// the provider. /// + [Obsolete("Use IUserTwoFactorLoginService.GetSetupInfoWithStatusAsync. This will be removed in Umbraco 15.")] Task GetSetupInfoAsync(Guid userOrMemberKey, string providerName); /// @@ -59,10 +60,13 @@ public interface ITwoFactorLoginService : IService /// /// Disables 2FA with Code. /// + [Obsolete("Use IUserTwoFactorLoginService.DisableByCodeWithStatusAsync. This will be removed in Umbraco 15.")] Task DisableWithCodeAsync(string providerName, Guid userOrMemberKey, string code); /// /// Validates and Saves. /// + [Obsolete("Use IUserTwoFactorLoginService.ValidateAndSaveWithStatusAsync. This will be removed in Umbraco 15.")] Task ValidateAndSaveAsync(string providerName, Guid userKey, string secret, string code); + } diff --git a/src/Umbraco.Core/Services/IUserTwoFactorLoginService.cs b/src/Umbraco.Core/Services/IUserTwoFactorLoginService.cs new file mode 100644 index 0000000000..e5dafd1d96 --- /dev/null +++ b/src/Umbraco.Core/Services/IUserTwoFactorLoginService.cs @@ -0,0 +1,37 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +/// +/// A user specific Two factor service, that ensures the user exists before doing the job. +/// +public interface IUserTwoFactorLoginService +{ + /// + /// Disables a specific two factor provider on a specific user. + /// + Task> DisableAsync(Guid userKey, string providerName); + + /// + /// Gets the two factor providers on a specific user. + /// + Task, TwoFactorOperationStatus>> GetProviderNamesAsync(Guid userKey); + + /// + /// The returned type can be anything depending on the setup providers. You will need to cast it to the type handled by + /// the provider. + /// + Task> GetSetupInfoAsync(Guid userKey, string providerName); + + /// + /// Validates and Saves. + /// + Task> ValidateAndSaveAsync(string providerName, Guid userKey, string modelSecret, string modelCode); + + /// + /// Disables 2FA with Code. + /// + Task> DisableByCodeAsync(string providerName, Guid userKey, string code); +} diff --git a/src/Umbraco.Core/Services/OperationStatus/TwoFactorOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/TwoFactorOperationStatus.cs new file mode 100644 index 0000000000..e9b64b460a --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/TwoFactorOperationStatus.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum TwoFactorOperationStatus +{ + Success, + ProviderAlreadySetup, + ProviderNameNotFound, + InvalidCode, + UserNotFound +} diff --git a/src/Umbraco.Core/Services/TwoFactorLoginService.cs b/src/Umbraco.Core/Services/TwoFactorLoginService.cs index bcfa102d1f..cb888c61e8 100644 --- a/src/Umbraco.Core/Services/TwoFactorLoginService.cs +++ b/src/Umbraco.Core/Services/TwoFactorLoginService.cs @@ -1,8 +1,6 @@ using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; @@ -52,6 +50,8 @@ public class TwoFactorLoginService : ITwoFactorLoginService public async Task> GetEnabledTwoFactorProviderNamesAsync(Guid userOrMemberKey) => await GetEnabledProviderNamesAsync(userOrMemberKey); + /// + [Obsolete("Use DisableByCodeWithStatusAsync. This will be removed in Umbraco 15.")] public async Task DisableWithCodeAsync(string providerName, Guid userOrMemberKey, string code) { var secret = await GetSecretForUserAndProviderAsync(userOrMemberKey, providerName); @@ -71,6 +71,7 @@ public class TwoFactorLoginService : ITwoFactorLoginService return await DisableAsync(userOrMemberKey, providerName); } + [Obsolete("Use ValidateAndSaveWithStatusAsync. This will be removed in Umbraco 15.")] public async Task ValidateAndSaveAsync(string providerName, Guid userOrMemberKey, string secret, string code) { try @@ -114,6 +115,7 @@ public class TwoFactorLoginService : ITwoFactorLoginService } /// + [Obsolete("Use GetSetupInfoWithStatusAsync(). This will be removed in Umbraco 15.")] public async Task GetSetupInfoAsync(Guid userOrMemberKey, string providerName) { var secret = await GetSecretForUserAndProviderAsync(userOrMemberKey, providerName); diff --git a/src/Umbraco.Core/Services/TwoFactorLoginServiceBase.cs b/src/Umbraco.Core/Services/TwoFactorLoginServiceBase.cs new file mode 100644 index 0000000000..d8d69bfb26 --- /dev/null +++ b/src/Umbraco.Core/Services/TwoFactorLoginServiceBase.cs @@ -0,0 +1,141 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Base class for setting up members or users to use 2FA. +/// +internal abstract class TwoFactorLoginServiceBase +{ + private readonly ITwoFactorLoginService _twoFactorLoginService; + private readonly ICoreScopeProvider _scopeProvider; + private readonly IDictionary _twoFactorSetupGenerators; + + protected TwoFactorLoginServiceBase(ITwoFactorLoginService twoFactorLoginService, IEnumerable twoFactorSetupGenerators, ICoreScopeProvider scopeProvider) + { + _twoFactorLoginService = twoFactorLoginService; + _scopeProvider = scopeProvider; + _twoFactorSetupGenerators = twoFactorSetupGenerators.ToDictionary(x => x.ProviderName); + } + + public virtual async Task> DisableAsync(Guid userKey, string providerName) + { + var result = await _twoFactorLoginService.DisableAsync(userKey, providerName); + + return result + ? Attempt.Succeed(TwoFactorOperationStatus.Success) + : Attempt.Fail(TwoFactorOperationStatus.ProviderNameNotFound); + } + + /// + /// Gets the two factor providers on a specific user. + /// + public virtual async Task, TwoFactorOperationStatus>> GetProviderNamesAsync(Guid userKey) + { + IEnumerable allProviders = _twoFactorLoginService.GetAllProviderNames(); + var userProviders =(await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(userKey)).ToHashSet(); + + IEnumerable result = allProviders.Select(x => new UserTwoFactorProviderModel(x, userProviders.Contains(x))); + return Attempt.SucceedWithStatus(TwoFactorOperationStatus.Success, result); + } + + /// + /// Generates a new random unique secret. + /// + /// The random secret + protected virtual string GenerateSecret() => Guid.NewGuid().ToString(); + + public virtual async Task> GetSetupInfoAsync(Guid userOrMemberKey, string providerName) + { + var secret = await _twoFactorLoginService.GetSecretForUserAndProviderAsync(userOrMemberKey, providerName); + + // Dont allow to generate a new secrets if user already has one + if (!string.IsNullOrEmpty(secret)) + { + return Attempt.FailWithStatus(TwoFactorOperationStatus.ProviderAlreadySetup, new NoopSetupTwoFactorModel()); + } + + secret = GenerateSecret(); + + if (!_twoFactorSetupGenerators.TryGetValue(providerName, out ITwoFactorProvider? generator)) + { + return Attempt.FailWithStatus(TwoFactorOperationStatus.ProviderNameNotFound, new NoopSetupTwoFactorModel()); + } + + ISetupTwoFactorModel result= await generator.GetSetupDataAsync(userOrMemberKey, secret); + return Attempt.SucceedWithStatus(TwoFactorOperationStatus.Success, result); + } + + public virtual async Task> ValidateAndSaveAsync( + string providerName, + Guid userOrMemberKey, + string secret, + string code) + { + using var scope = _scopeProvider.CreateCoreScope(); + + if ((await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(userOrMemberKey)).Contains(providerName)) + { + return Attempt.Fail(TwoFactorOperationStatus.ProviderAlreadySetup); + } + + bool valid; + try + { + valid = _twoFactorLoginService.ValidateTwoFactorSetup(providerName, secret, code); + } + catch (InvalidOperationException) + { + return Attempt.Fail(TwoFactorOperationStatus.ProviderNameNotFound); + } + + if (valid is false) + { + return Attempt.Fail(TwoFactorOperationStatus.InvalidCode); + } + + var twoFactorLogin = new TwoFactorLogin + { + Confirmed = true, + Secret = secret, + UserOrMemberKey = userOrMemberKey, + ProviderName = providerName, + }; + + + await _twoFactorLoginService.SaveAsync(twoFactorLogin); + + scope.Complete(); + return Attempt.Succeed(TwoFactorOperationStatus.Success); + } + + /// + /// Disables 2FA with Code. + /// + public async Task> DisableByCodeAsync(string providerName, Guid userOrMemberKey, string code) + { + var secret = await _twoFactorLoginService.GetSecretForUserAndProviderAsync(userOrMemberKey, providerName); + + if (!_twoFactorSetupGenerators.TryGetValue(providerName, out ITwoFactorProvider? generator)) + { + return Attempt.Fail(TwoFactorOperationStatus.ProviderNameNotFound); + } + + var isValid = secret is not null && generator.ValidateTwoFactorPIN(secret, code); + + if (!isValid) + { + return Attempt.Fail(TwoFactorOperationStatus.InvalidCode); + } + + var success = await _twoFactorLoginService.DisableAsync(userOrMemberKey, providerName); + + return success + ? Attempt.Succeed(TwoFactorOperationStatus.Success) + : Attempt.Fail(TwoFactorOperationStatus.InvalidCode); + } +} diff --git a/src/Umbraco.Core/Services/UserTwoFactorLoginService.cs b/src/Umbraco.Core/Services/UserTwoFactorLoginService.cs new file mode 100644 index 0000000000..89241d31f2 --- /dev/null +++ b/src/Umbraco.Core/Services/UserTwoFactorLoginService.cs @@ -0,0 +1,73 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +/// +internal class UserTwoFactorLoginService : TwoFactorLoginServiceBase, IUserTwoFactorLoginService +{ + private readonly IUserService _userService; + + public UserTwoFactorLoginService( + ITwoFactorLoginService twoFactorLoginService, + IEnumerable twoFactorSetupGenerators, + IUserService userService, + ICoreScopeProvider scopeProvider) + : base(twoFactorLoginService, twoFactorSetupGenerators, scopeProvider) => + _userService = userService; + + /// + public override async Task> DisableAsync(Guid userKey, string providerName) + { + IUser? user = await _userService.GetAsync(userKey); + + if (user is null) + { + return Attempt.Fail(TwoFactorOperationStatus.UserNotFound); + } + + return await base.DisableAsync(userKey, providerName); + } + + /// + public override async Task, TwoFactorOperationStatus>> GetProviderNamesAsync(Guid userKey) + { + IUser? user = await _userService.GetAsync(userKey); + + if (user is null) + { + return Attempt.FailWithStatus(TwoFactorOperationStatus.UserNotFound, Enumerable.Empty()); + } + + return await base.GetProviderNamesAsync(userKey); + } + + /// + public override async Task> GetSetupInfoAsync(Guid userKey, string providerName) + { + IUser? user = await _userService.GetAsync(userKey); + + if (user is null) + { + return Attempt.FailWithStatus(TwoFactorOperationStatus.UserNotFound, new NoopSetupTwoFactorModel()); + } + + return await base.GetSetupInfoAsync(userKey, providerName); + } + + /// + public override async Task> ValidateAndSaveAsync(string providerName, Guid userKey, string secret, string code) + { + IUser? user = await _userService.GetAsync(userKey); + + if (user is null) + { + return Attempt.Fail(TwoFactorOperationStatus.UserNotFound); + } + + return await base.ValidateAndSaveAsync(providerName, userKey, secret, code); + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index e22d06ae68..d86db99096 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -50,7 +50,6 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); - builder.Services.AddUnique(); builder.Services.AddTransient(CreateLocalizedTextServiceFileSourcesFactory); builder.Services.AddUnique(factory => CreatePackageRepository(factory, "createdPackages.config")); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeClaimsPrincipalFactory.cs b/src/Umbraco.Infrastructure/Security/BackOfficeClaimsPrincipalFactory.cs index a6589166b2..c1a69d24e7 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeClaimsPrincipalFactory.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeClaimsPrincipalFactory.cs @@ -10,6 +10,8 @@ namespace Umbraco.Cms.Core.Security; /// public class BackOfficeClaimsPrincipalFactory : UserClaimsPrincipalFactory { + private readonly IOptions _backOfficeAuthenticationTypeSettings; + /// /// Initializes a new instance of the class. /// @@ -17,12 +19,15 @@ public class BackOfficeClaimsPrincipalFactory : UserClaimsPrincipalFactoryThe public BackOfficeClaimsPrincipalFactory( UserManager userManager, - IOptions optionsAccessor) + IOptions optionsAccessor, + IOptions backOfficeAuthenticationTypeSettings + ) : base(userManager, optionsAccessor) { + _backOfficeAuthenticationTypeSettings = backOfficeAuthenticationTypeSettings; } - protected virtual string AuthenticationType { get; } = Constants.Security.BackOfficeAuthenticationType; + protected virtual string AuthenticationType => _backOfficeAuthenticationTypeSettings.Value.AuthenticationType; /// protected override async Task GenerateClaimsAsync(BackOfficeIdentityUser user) diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index dcab6871d6..9f79b5e3bb 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -73,7 +73,8 @@ public static partial class UmbracoBuilderExtensions return builder; } - private static BackOfficeIdentityBuilder BuildUmbracoBackOfficeIdentity(this IUmbracoBuilder builder) + //TODO change this to private when the legacy backoffice is removed + public static BackOfficeIdentityBuilder BuildUmbracoBackOfficeIdentity(this IUmbracoBuilder builder) { IServiceCollection services = builder.Services; diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs index 8af883135d..4969a4e5c9 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs @@ -22,6 +22,7 @@ namespace Umbraco.Cms.Web.BackOffice.Security; public class BackOfficeSignInManager : UmbracoSignInManager, IBackOfficeSignInManager { private readonly IEventAggregator _eventAggregator; + private readonly IOptions _backOfficeAuthenticationTypeSettings; private readonly IBackOfficeExternalLoginProviders _externalLogins; private readonly GlobalSettings _globalSettings; private readonly BackOfficeUserManager _userManager; @@ -37,76 +38,26 @@ public class BackOfficeSignInManager : UmbracoSignInManager confirmation, IEventAggregator eventAggregator, - IOptions securitySettings) + IOptions securitySettings, + IOptions backOfficeAuthenticationTypeSettings + ) : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, securitySettings) { _userManager = userManager; _externalLogins = externalLogins; _eventAggregator = eventAggregator; + _backOfficeAuthenticationTypeSettings = backOfficeAuthenticationTypeSettings; _globalSettings = globalSettings.Value; } - [Obsolete("Use non-obsolete constructor. This is scheduled for removal in V14.")] - public BackOfficeSignInManager( - BackOfficeUserManager userManager, - IHttpContextAccessor contextAccessor, - IBackOfficeExternalLoginProviders externalLogins, - IUserClaimsPrincipalFactory claimsFactory, - IOptions optionsAccessor, - IOptions globalSettings, - ILogger> logger, - IAuthenticationSchemeProvider schemes, - IUserConfirmation confirmation, - IEventAggregator eventAggregator) - : this( - userManager, - contextAccessor, - externalLogins, - claimsFactory, - optionsAccessor, - globalSettings, - logger, - schemes, - confirmation, - eventAggregator, - StaticServiceProvider.Instance.GetRequiredService>()) - { - } - [Obsolete("Use non-obsolete constructor. This is scheduled for removal in V14.")] - public BackOfficeSignInManager( - BackOfficeUserManager userManager, - IHttpContextAccessor contextAccessor, - IBackOfficeExternalLoginProviders externalLogins, - IUserClaimsPrincipalFactory claimsFactory, - IOptions optionsAccessor, - IOptions globalSettings, - ILogger> logger, - IAuthenticationSchemeProvider schemes, - IUserConfirmation confirmation) - : this( - userManager, - contextAccessor, - externalLogins, - claimsFactory, - optionsAccessor, - globalSettings, - logger, - schemes, - confirmation, - StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService>()) - { - } + protected override string AuthenticationType => _backOfficeAuthenticationTypeSettings.Value.AuthenticationType; - protected override string AuthenticationType => Constants.Security.BackOfficeAuthenticationType; + protected override string ExternalAuthenticationType => _backOfficeAuthenticationTypeSettings.Value.ExternalAuthenticationType; - protected override string ExternalAuthenticationType => Constants.Security.BackOfficeExternalAuthenticationType; + protected override string TwoFactorAuthenticationType => _backOfficeAuthenticationTypeSettings.Value.TwoFactorAuthenticationType; - protected override string TwoFactorAuthenticationType => Constants.Security.BackOfficeTwoFactorAuthenticationType; - - protected override string TwoFactorRememberMeAuthenticationType => - Constants.Security.BackOfficeTwoFactorRememberMeAuthenticationType; + protected override string TwoFactorRememberMeAuthenticationType =>_backOfficeAuthenticationTypeSettings.Value.TwoFactorRememberMeAuthenticationType; /// /// Custom ExternalLoginSignInAsync overload for handling external sign in with auto-linking diff --git a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeCookieOptions.cs b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeCookieOptions.cs index fea4d3b4a1..a7be05ad55 100644 --- a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeCookieOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeCookieOptions.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Net; using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Web.BackOffice.Controllers; @@ -24,6 +25,7 @@ namespace Umbraco.Cms.Web.BackOffice.Security; public class ConfigureBackOfficeCookieOptions : IConfigureNamedOptions { private readonly IBasicAuthService _basicAuthService; + private readonly IOptions _backOfficeAuthenticationTypeSettings; private readonly IDataProtectionProvider _dataProtection; private readonly GlobalSettings _globalSettings; private readonly IHostingEnvironment _hostingEnvironment; @@ -63,7 +65,9 @@ public class ConfigureBackOfficeCookieOptions : IConfigureNamedOptions backOfficeAuthenticationTypeSettings + ) { _serviceProvider = serviceProvider; _umbracoContextAccessor = umbracoContextAccessor; @@ -77,12 +81,14 @@ public class ConfigureBackOfficeCookieOptions : IConfigureNamedOptions _backOfficeAuthenticationTypeSettings.Value.AuthenticationType; /// public void Configure(string? name, CookieAuthenticationOptions options) { - if (name != Constants.Security.BackOfficeAuthenticationType) + if (name != AuthenticationType) { return; } @@ -115,7 +121,7 @@ public class ConfigureBackOfficeCookieOptions : IConfigureNamedOptions(options => + { + options.AuthenticationType = Constants.Security.BackOfficeAuthenticationType; + options.ExternalAuthenticationType = Constants.Security.BackOfficeExternalAuthenticationType; + options.TwoFactorAuthenticationType = Constants.Security.BackOfficeTwoFactorAuthenticationType; + options.TwoFactorRememberMeAuthenticationType = Constants.Security.BackOfficeTwoFactorRememberMeAuthenticationType; + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs index 9d77c42bd1..72ea645d9a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Moq; using NUnit.Framework; +using Umbraco.Cms.Api.Management.DependencyInjection; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; @@ -70,6 +71,7 @@ public class BackOfficeExamineSearcherTests : ExamineBaseTest builder.AddNotificationHandler(); builder.AddExamineIndexes(); builder.AddBackOfficeIdentity(); + BackOfficeAuthBuilderExtensions.AddBackOfficeAuthentication(builder); builder.Services.AddHostedService(); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs index 10f324183d..85198d6691 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Moq; using NUnit.Framework; +using Umbraco.Cms.Api.Management.DependencyInjection; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models.ContentEditing; @@ -72,6 +73,7 @@ public class ExamineExternalIndexTests : ExamineBaseTest builder.AddNotificationHandler(); builder.AddExamineIndexes(); builder.AddBackOfficeIdentity(); + BackOfficeAuthBuilderExtensions.AddBackOfficeAuthentication(builder); builder.Services.AddHostedService(); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackOffice/BackOfficeClaimsPrincipalFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackOffice/BackOfficeClaimsPrincipalFactoryTests.cs index afda7c5cbf..026402b9f9 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackOffice/BackOfficeClaimsPrincipalFactoryTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackOffice/BackOfficeClaimsPrincipalFactoryTests.cs @@ -56,18 +56,21 @@ public class BackOfficeClaimsPrincipalFactoryTests public void Ctor_When_UserManager_Is_Null_Expect_ArgumentNullException() => Assert.Throws(() => new BackOfficeClaimsPrincipalFactory( null, - new OptionsWrapper(new BackOfficeIdentityOptions()))); + new OptionsWrapper(new BackOfficeIdentityOptions()), + new OptionsWrapper(new BackOfficeAuthenticationTypeSettings()) + )); [Test] public void Ctor_When_Options_Are_Null_Expect_ArgumentNullException() => Assert.Throws(() => - new BackOfficeClaimsPrincipalFactory(GetMockedUserManager().Object, null)); + new BackOfficeClaimsPrincipalFactory(GetMockedUserManager().Object, null, new OptionsWrapper(new BackOfficeAuthenticationTypeSettings())) + ); [Test] public void Ctor_When_Options_Value_Is_Null_Expect_ArgumentException() => Assert.Throws(() => new BackOfficeClaimsPrincipalFactory( GetMockedUserManager().Object, - new OptionsWrapper(null))); + new OptionsWrapper(null), new OptionsWrapper(new BackOfficeAuthenticationTypeSettings()))); [Test] public void CreateAsync_When_User_Is_Null_Expect_ArgumentNullException() @@ -158,5 +161,5 @@ public class BackOfficeClaimsPrincipalFactoryTests private BackOfficeClaimsPrincipalFactory CreateSut() => new( _mockUserManager.Object, - new OptionsWrapper(new BackOfficeIdentityOptions())); + new OptionsWrapper(new BackOfficeIdentityOptions()), new OptionsWrapper(new BackOfficeAuthenticationTypeSettings())); }