diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs index be45e4b812..b019d03635 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs @@ -13,7 +13,7 @@ namespace Umbraco.Cms.Api.Management.Controllers; [Authorize(Policy = "New" + AuthorizationPolicies.BackOfficeAccess)] [MapToApi(ManagementApiConfiguration.ApiName)] [JsonOptionsName(Constants.JsonOptionsNames.BackOffice)] -public class ManagementApiControllerBase : Controller +public abstract class ManagementApiControllerBase : Controller { protected CreatedAtActionResult CreatedAtAction(Expression> action, Guid id) => CreatedAtAction(action, new { id = id }); @@ -46,7 +46,6 @@ public class ManagementApiControllerBase : Controller protected static Guid CurrentUserKey(IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { - //FIXME - Throw if no current user, when we are able to get the current user return backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key ?? throw new InvalidOperationException("No backoffice user found"); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs index eaaded72ca..b61a16adec 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs @@ -23,8 +23,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Security; [ApiVersion("1.0")] [ApiController] [VersionedApiBackOfficeRoute(Paths.BackOfficeApiEndpointTemplate)] -[ApiExplorerSettings(GroupName = "Security")] -public class BackOfficeController : ManagementApiControllerBase +public class BackOfficeController : SecurityControllerBase { private readonly IHttpContextAccessor _httpContextAccessor; private readonly IBackOfficeSignInManager _backOfficeSignInManager; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/ResetPasswordController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/ResetPasswordController.cs new file mode 100644 index 0000000000..a39db00762 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/ResetPasswordController.cs @@ -0,0 +1,37 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Filters; +using Umbraco.Cms.Api.Management.ViewModels.Security; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Security; + +[ApiVersion("1.0")] +public class ResetPasswordController : SecurityControllerBase +{ + private readonly IUserService _userService; + + public ResetPasswordController(IUserService userService) => _userService = userService; + + [HttpPost("forgot-password")] + [MapToApiVersion("1.0")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [UserPasswordEnsureMinimumResponseTime] + public async Task RequestPasswordReset(ResetPasswordRequestModel model) + { + Attempt result = await _userService.SendResetPasswordEmailAsync(model.Email); + + // If this feature is switched off in configuration, the UI will be amended to not make the request to reset password available. + // So this is just a server-side secondary check. + // No matter what other status it will just return Ok, so you can't use this endpoint to determine whether the email exists in the system. + return result.Result == UserOperationStatus.CannotPasswordReset + ? BadRequest() + : Ok(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/ResetPasswordTokenController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/ResetPasswordTokenController.cs new file mode 100644 index 0000000000..c67545a4a2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/ResetPasswordTokenController.cs @@ -0,0 +1,37 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Management.Filters; +using Umbraco.Cms.Api.Management.ViewModels.Security; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Security; + +[ApiVersion("1.0")] +public class ResetPasswordTokenController : SecurityControllerBase +{ + private readonly IUserService _userService; + + public ResetPasswordTokenController(IUserService userService) => _userService = userService; + + [HttpPost("forgot-password/reset")] + [MapToApiVersion("1.0")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetailsBuilder), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetailsBuilder), StatusCodes.Status404NotFound)] + [UserPasswordEnsureMinimumResponseTime] + public async Task ResetPasswordToken(ResetPasswordTokenRequestModel model) + { + Attempt result = await _userService.ResetPasswordAsync(model.UserId, model.ResetCode, model.Password); + + return result.Success + ? NoContent() + : UserOperationStatusResult(result.Status, result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs new file mode 100644 index 0000000000..0da834d8ec --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs @@ -0,0 +1,35 @@ +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; + +namespace Umbraco.Cms.Api.Management.Controllers.Security; + +[ApiController] +[VersionedApiBackOfficeRoute("security")] +[ApiExplorerSettings(GroupName = "Security")] +public abstract class SecurityControllerBase : ManagementApiControllerBase +{ + protected IActionResult UserOperationStatusResult(UserOperationStatus status, ErrorMessageResult? errorMessageResult = null) => + status switch + { + UserOperationStatus.Success => Ok(), + UserOperationStatus.UserNotFound => NotFound(new ProblemDetailsBuilder() + .WithTitle("The user was not found") + .WithDetail("The specified user was not found.") + .Build()), + UserOperationStatus.InvalidPasswordResetToken => BadRequest(new ProblemDetailsBuilder() + .WithTitle("The password reset token was invalid") + .WithDetail("The specified password reset token was either used already or wrong.") + .Build()), + UserOperationStatus.UnknownFailure => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Unknown failure") + .WithDetail(errorMessageResult?.Error?.ErrorMessage ?? "The error was unknown") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() + .WithTitle("Unknown user operation status.") + .Build()), + }; +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/VerifyResetPasswordTokenController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/VerifyResetPasswordTokenController.cs new file mode 100644 index 0000000000..f0b83bb001 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/VerifyResetPasswordTokenController.cs @@ -0,0 +1,36 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Management.Filters; +using Umbraco.Cms.Api.Management.ViewModels.Security; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Security; + +[ApiVersion("1.0")] +public class VerifyResetPasswordTokenController : SecurityControllerBase +{ + private readonly IUserService _userService; + + public VerifyResetPasswordTokenController(IUserService userService) => _userService = userService; + + [HttpPost("forgot-password/verify")] + [MapToApiVersion("1.0")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetailsBuilder), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetailsBuilder), StatusCodes.Status404NotFound)] + [UserPasswordEnsureMinimumResponseTime] + public async Task VerifyResetPasswordToken(VerifyResetPasswordTokenRequestModel model) + { + Attempt result = await _userService.VerifyPasswordResetAsync(model.UserId, model.ResetCode); + + return result.Success + ? NoContent() + : UserOperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/UsersControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/UsersControllerBase.cs index 6c1ce2d897..6385dc3ac4 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/UsersControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/UsersControllerBase.cs @@ -104,7 +104,7 @@ public abstract class UserControllerBase : ManagementApiControllerBase .WithTitle("Invalid ISO code") .WithDetail("The specified ISO code is invalid.") .Build()), - UserOperationStatus.InvalidVerificationToken => BadRequest(new ProblemDetailsBuilder() + UserOperationStatus.InvalidInviteToken => BadRequest(new ProblemDetailsBuilder() .WithTitle("Invalid verification token") .WithDetail("The specified verification token is invalid.") .Build()), diff --git a/src/Umbraco.Cms.Api.Management/Filters/EnsureMinimumResponseTimeFilter.cs b/src/Umbraco.Cms.Api.Management/Filters/EnsureMinimumResponseTimeFilter.cs new file mode 100644 index 0000000000..fdf1161659 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Filters/EnsureMinimumResponseTimeFilter.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Umbraco.Cms.Api.Management.Filters; + +internal abstract class EnsureMinimumResponseTimeFilter : IAsyncActionFilter +{ + private readonly TimeSpan _minimumResponseTime; + + protected EnsureMinimumResponseTimeFilter(TimeSpan minimumResponseTime) => _minimumResponseTime = minimumResponseTime; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var stopwatch = new Stopwatch(); + + stopwatch.Start(); + await next(); + stopwatch.Stop(); + + TimeSpan forceWait = _minimumResponseTime.Subtract(stopwatch.Elapsed); + + if (forceWait.Microseconds > 0) + { + await Task.Delay(forceWait).ConfigureAwait(false); + } + } +} diff --git a/src/Umbraco.Cms.Api.Management/Filters/UserPasswordEnsureMinimumResponseTimeAttribute.cs b/src/Umbraco.Cms.Api.Management/Filters/UserPasswordEnsureMinimumResponseTimeAttribute.cs new file mode 100644 index 0000000000..ab4b1fcc82 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Filters/UserPasswordEnsureMinimumResponseTimeAttribute.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; + +namespace Umbraco.Cms.Api.Management.Filters; + +internal class UserPasswordEnsureMinimumResponseTimeAttribute : TypeFilterAttribute +{ + public UserPasswordEnsureMinimumResponseTimeAttribute() + : base(typeof(UserPasswordEnsureMinimumResponseTimeFilter)) + { + } + + private class UserPasswordEnsureMinimumResponseTimeFilter : EnsureMinimumResponseTimeFilter + { + public UserPasswordEnsureMinimumResponseTimeFilter(IOptions options) + : base(options.Value.MinimumResponseTime) + { + } + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 06c7657cbd..d29df7017a 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -12235,6 +12235,295 @@ } } }, + "/umbraco/management/api/v1/security/forgot-password": { + "post": { + "tags": [ + "Security" + ], + "operationId": "PostSecurityForgotPassword", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + }, + "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" + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/security/forgot-password/reset": { + "post": { + "tags": [ + "Security" + ], + "operationId": "PostSecurityForgotPasswordReset", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" + } + ] + } + } + } + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + }, + "text/plain": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + }, + "text/plain": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/security/forgot-password/verify": { + "post": { + "tags": [ + "Security" + ], + "operationId": "PostSecurityForgotPasswordVerify", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/VerifyResetPasswordTokenRequestModel" + }, + { + "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/VerifyResetPasswordTokenRequestModel" + }, + { + "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/VerifyResetPasswordTokenRequestModel" + }, + { + "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" + } + ] + } + } + } + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + }, + "text/plain": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + }, + "text/plain": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] + } + } + } + } + } + } + }, "/umbraco/management/api/v1/server/status": { "get": { "tags": [ @@ -20682,6 +20971,10 @@ }, "additionalProperties": { } }, + "ProblemDetailsBuilderModel": { + "type": "object", + "additionalProperties": false + }, "ProfilingStatusRequestModel": { "type": "object", "properties": { @@ -21106,6 +21399,29 @@ }, "additionalProperties": false }, + "ResetPasswordRequestModel": { + "type": "object", + "properties": { + "email": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ResetPasswordTokenRequestModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/VerifyResetPasswordTokenRequestModel" + } + ], + "properties": { + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, "RichTextRuleModel": { "type": "object", "properties": { @@ -22697,6 +23013,19 @@ }, "additionalProperties": false }, + "VerifyResetPasswordTokenRequestModel": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "resetCode": { + "type": "string" + } + }, + "additionalProperties": false + }, "VersionResponseModel": { "type": "object", "properties": { diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Security/ResetPasswordRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Security/ResetPasswordRequestModel.cs new file mode 100644 index 0000000000..038d3867c0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Security/ResetPasswordRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Security; + +public class ResetPasswordRequestModel +{ + public string Email { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Security/ResetPasswordTokenRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Security/ResetPasswordTokenRequestModel.cs new file mode 100644 index 0000000000..fee30b6caa --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Security/ResetPasswordTokenRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Security; + +public class ResetPasswordTokenRequestModel : VerifyResetPasswordTokenRequestModel +{ + public string Password { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Security/VerifyResetPasswordTokenRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Security/VerifyResetPasswordTokenRequestModel.cs new file mode 100644 index 0000000000..3d7bec94d3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Security/VerifyResetPasswordTokenRequestModel.cs @@ -0,0 +1,7 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Security; + +public class VerifyResetPasswordTokenRequestModel +{ + public Guid UserId { get; set; } + public string ResetCode { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Core/Configuration/Models/UserPasswordConfigurationSettings.cs b/src/Umbraco.Core/Configuration/Models/UserPasswordConfigurationSettings.cs index 156f90419c..d21d5c679d 100644 --- a/src/Umbraco.Core/Configuration/Models/UserPasswordConfigurationSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/UserPasswordConfigurationSettings.cs @@ -17,6 +17,7 @@ public class UserPasswordConfigurationSettings : IPasswordConfiguration internal const bool StaticRequireLowercase = false; internal const bool StaticRequireUppercase = false; internal const int StaticMaxFailedAccessAttemptsBeforeLockout = 5; + internal const string StaticMinimumResponseTime = "0.00:00:02"; /// [DefaultValue(StaticRequiredLength)] @@ -45,4 +46,10 @@ public class UserPasswordConfigurationSettings : IPasswordConfiguration /// [DefaultValue(StaticMaxFailedAccessAttemptsBeforeLockout)] public int MaxFailedAccessAttemptsBeforeLockout { get; set; } = StaticMaxFailedAccessAttemptsBeforeLockout; + + /// + /// Gets or sets the minimum response time of the forgot password request. + /// + [DefaultValue(StaticMinimumResponseTime)] + public TimeSpan MinimumResponseTime { get; set; } = TimeSpan.Parse(StaticMinimumResponseTime); } diff --git a/src/Umbraco.Core/Models/ChangingPasswordModel.cs b/src/Umbraco.Core/Models/ChangingPasswordModel.cs index ecba35f137..9b01061f8e 100644 --- a/src/Umbraco.Core/Models/ChangingPasswordModel.cs +++ b/src/Umbraco.Core/Models/ChangingPasswordModel.cs @@ -25,4 +25,9 @@ public class ChangingPasswordModel /// [DataMember(Name = "id")] public int Id { get; set; } + + /// + /// The reset token that is required if changing your own password without the old password. + /// + public string? ResetPasswordToken { get; set; } } diff --git a/src/Umbraco.Core/Models/Membership/ChangeBackOfficeUserPasswordModel.cs b/src/Umbraco.Core/Models/Membership/ChangeBackOfficeUserPasswordModel.cs index d2b9219e2c..0292d30809 100644 --- a/src/Umbraco.Core/Models/Membership/ChangeBackOfficeUserPasswordModel.cs +++ b/src/Umbraco.Core/Models/Membership/ChangeBackOfficeUserPasswordModel.cs @@ -13,4 +13,9 @@ public class ChangeBackOfficeUserPasswordModel /// The user requesting the password change /// public required IUser User { get; set; } + + /// + /// The reset token that is required if changing your own password without the old password. + /// + public string? ResetPasswordToken { get; set; } } diff --git a/src/Umbraco.Core/Models/Membership/ChangeUserPasswordModel.cs b/src/Umbraco.Core/Models/Membership/ChangeUserPasswordModel.cs index ac0dbefc6a..32d0bbd817 100644 --- a/src/Umbraco.Core/Models/Membership/ChangeUserPasswordModel.cs +++ b/src/Umbraco.Core/Models/Membership/ChangeUserPasswordModel.cs @@ -6,5 +6,7 @@ public class ChangeUserPasswordModel public string? OldPassword { get; set; } + public string? ResetPasswordToken { get; set; } + public Guid UserKey { get; set; } } diff --git a/src/Umbraco.Core/Models/UserForgotPasswordMessage.cs b/src/Umbraco.Core/Models/UserForgotPasswordMessage.cs new file mode 100644 index 0000000000..ad25fd1b72 --- /dev/null +++ b/src/Umbraco.Core/Models/UserForgotPasswordMessage.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Core.Models; + +public class UserForgotPasswordMessage +{ + public required Uri ForgotPasswordUri { get; set; } + + public required IUser Recipient { get; set; } +} diff --git a/src/Umbraco.Core/Security/IBackOfficePasswordChanger.cs b/src/Umbraco.Core/Security/IBackOfficePasswordChanger.cs index d0e2254f68..2bb1ac5a49 100644 --- a/src/Umbraco.Core/Security/IBackOfficePasswordChanger.cs +++ b/src/Umbraco.Core/Security/IBackOfficePasswordChanger.cs @@ -5,5 +5,5 @@ namespace Umbraco.Cms.Core.Security; public interface IBackOfficePasswordChanger { - Task> ChangeBackOfficePassword(ChangeBackOfficeUserPasswordModel model); + Task> ChangeBackOfficePassword(ChangeBackOfficeUserPasswordModel model, IUser? performingUser); } diff --git a/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs b/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs index 04efcaecdc..d9225e372a 100644 --- a/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs +++ b/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs @@ -1,4 +1,5 @@ -using Umbraco.Cms.Core.Models; +using System.Security.Principal; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Services.OperationStatus; @@ -16,10 +17,13 @@ public interface ICoreBackOfficeUserManager Task CreateForInvite(UserCreateModel createModel); Task> GenerateEmailConfirmationTokenAsync(IUser user); + Task> GeneratePasswordResetTokenAsync(IUser user); Task> UnlockUser(IUser user); Task, UserOperationStatus>> GetLoginsAsync(IUser user); Task IsEmailConfirmationTokenValidAsync(IUser user, string token); + Task IsResetPasswordTokenValidAsync(IUser user, string token); + void NotifyForgotPasswordRequested(IPrincipal user, string toString); } diff --git a/src/Umbraco.Core/Security/IForgotPasswordUriProvider.cs b/src/Umbraco.Core/Security/IForgotPasswordUriProvider.cs new file mode 100644 index 0000000000..76c4527ad0 --- /dev/null +++ b/src/Umbraco.Core/Security/IForgotPasswordUriProvider.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Security; + +public interface IForgotPasswordUriProvider +{ + Task> CreateForgotPasswordUriAsync(IUser user); +} diff --git a/src/Umbraco.Core/Security/IUserForgotPasswordSender.cs b/src/Umbraco.Core/Security/IUserForgotPasswordSender.cs new file mode 100644 index 0000000000..8ab3a05082 --- /dev/null +++ b/src/Umbraco.Core/Security/IUserForgotPasswordSender.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Security; + +public interface IUserForgotPasswordSender +{ + Task SendForgotPassword(UserForgotPasswordMessage message); + + bool CanSend(); +} diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index 41cb6cae01..79e1738e95 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -382,4 +382,24 @@ public interface IUserService : IMembershipUserService #endregion + /// + /// Verifies the reset code sent from the reset password mail for a given user. + /// + /// The unique key of the user. + /// The reset password token. + Task> VerifyPasswordResetAsync(Guid userKey, string token); + + /// + /// Changes the user's password. + /// + /// The unique key of the user. + /// The reset password token. + /// The new password of the user. + Task> ResetPasswordAsync(Guid userKey, string token, string password); + + /// + /// Sends an email with a link to reset user's password. + /// + /// The email address of the user. + Task> SendResetPasswordEmailAsync(string userEmail); } diff --git a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs index 17275d5b70..739305f0c2 100644 --- a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs @@ -23,10 +23,12 @@ public enum UserOperationStatus OldPasswordRequired, InvalidAvatar, InvalidIsoCode, - InvalidVerificationToken, + InvalidInviteToken, + InvalidPasswordResetToken, ContentStartNodeNotFound, MediaStartNodeNotFound, ContentNodeNotFound, MediaNodeNotFound, UnknownFailure, + CannotPasswordReset } diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index c82d1deb02..6a2f7c440d 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq.Expressions; +using System.Security.Claims; using System.Security.Cryptography; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -42,6 +43,7 @@ internal class UserService : RepositoryService, IUserService private readonly IEntityService _entityService; private readonly ILocalLoginSettingProvider _localLoginSettingProvider; private readonly IUserInviteSender _inviteSender; + private readonly IUserForgotPasswordSender _forgotPasswordSender; private readonly MediaFileManager _mediaFileManager; private readonly ITemporaryFileService _temporaryFileService; private readonly IShortStringHelper _shortStringHelper; @@ -66,7 +68,8 @@ internal class UserService : RepositoryService, IUserService ITemporaryFileService temporaryFileService, IShortStringHelper shortStringHelper, IOptions contentSettings, - IIsoCodeValidator isoCodeValidator) + IIsoCodeValidator isoCodeValidator, + IUserForgotPasswordSender forgotPasswordSender) : base(provider, loggerFactory, eventMessagesFactory) { _userRepository = userRepository; @@ -80,6 +83,7 @@ internal class UserService : RepositoryService, IUserService _temporaryFileService = temporaryFileService; _shortStringHelper = shortStringHelper; _isoCodeValidator = isoCodeValidator; + _forgotPasswordSender = forgotPasswordSender; _globalSettings = globalSettings.Value; _securitySettings = securitySettings.Value; _contentSettings = contentSettings.Value; @@ -680,6 +684,46 @@ internal class UserService : RepositoryService, IUserService return Attempt.SucceedWithStatus(UserOperationStatus.Success, creationResult); } + public async Task> SendResetPasswordEmailAsync(string userEmail) + { + if (_forgotPasswordSender.CanSend() is false) + { + return Attempt.Fail(UserOperationStatus.CannotPasswordReset); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); + + ICoreBackOfficeUserManager userManager = serviceScope.ServiceProvider.GetRequiredService(); + IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); + + IUser? user = await userStore.GetByEmailAsync(userEmail); + + if (user is null) + { + return Attempt.Fail(UserOperationStatus.UserNotFound); + } + + IForgotPasswordUriProvider uriProvider = serviceScope.ServiceProvider.GetRequiredService(); + Attempt uriAttempt = await uriProvider.CreateForgotPasswordUriAsync(user); + if (uriAttempt.Success is false) + { + return Attempt.Fail(uriAttempt.Status); + } + + var message = new UserForgotPasswordMessage + { + ForgotPasswordUri = uriAttempt.Result, + Recipient = user, + }; + await _forgotPasswordSender.SendForgotPassword(message); + + userManager.NotifyForgotPasswordRequested(new ClaimsPrincipal(), user.Id.ToString()); //A bit of a hack, but since this method will be used without a signed in user, there is no real principal anyway. + + scope.Complete(); + + return Attempt.Succeed(UserOperationStatus.Success); + } public async Task> InviteAsync(Guid userKey, UserInviteModel model) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); @@ -1025,7 +1069,7 @@ internal class UserService : RepositoryService, IUserService return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PasswordChangedModel()); } - if (performingUser.UserState != UserState.Invited && performingUser.Username == user.Username && string.IsNullOrEmpty(model.OldPassword)) + if (performingUser.UserState != UserState.Invited && performingUser.Username == user.Username && string.IsNullOrEmpty(model.OldPassword) && string.IsNullOrEmpty(model.ResetPasswordToken)) { return Attempt.FailWithStatus(UserOperationStatus.OldPasswordRequired, new PasswordChangedModel()); } @@ -1035,13 +1079,24 @@ internal class UserService : RepositoryService, IUserService return Attempt.FailWithStatus(UserOperationStatus.Forbidden, new PasswordChangedModel()); } + if (string.IsNullOrEmpty(model.ResetPasswordToken) is false) + { + Attempt verifyPasswordResetAsync = await VerifyPasswordResetAsync(userKey, model.ResetPasswordToken); + if (verifyPasswordResetAsync.Result != UserOperationStatus.Success) + { + return Attempt.FailWithStatus(verifyPasswordResetAsync.Result, new PasswordChangedModel()); + } + } + IBackOfficePasswordChanger passwordChanger = serviceScope.ServiceProvider.GetRequiredService(); - Attempt result = await passwordChanger.ChangeBackOfficePassword(new ChangeBackOfficeUserPasswordModel + Attempt result = await passwordChanger.ChangeBackOfficePassword( + new ChangeBackOfficeUserPasswordModel { NewPassword = model.NewPassword, OldPassword = model.OldPassword, User = user, - }); + ResetPasswordToken = model.ResetPasswordToken, + }, performingUser); if (result.Success is false) { @@ -1899,13 +1954,39 @@ internal class UserService : RepositoryService, IUserService } } + public async Task> VerifyPasswordResetAsync(Guid userKey, string token) + { + var decoded = token.FromUrlBase64(); + + if (decoded is null) + { + return Attempt.Fail(UserOperationStatus.InvalidPasswordResetToken); + } + + IUser? user = await GetAsync(userKey); + + if (user is null) + { + return Attempt.Fail(UserOperationStatus.UserNotFound); + } + + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + ICoreBackOfficeUserManager backOfficeUserManager = scope.ServiceProvider.GetRequiredService(); + + var isValid = await backOfficeUserManager.IsResetPasswordTokenValidAsync(user, decoded); + + return isValid + ? Attempt.Succeed(UserOperationStatus.Success) + : Attempt.Fail(UserOperationStatus.InvalidPasswordResetToken); + } + public async Task> VerifyInviteAsync(Guid userKey, string token) { var decoded = token.FromUrlBase64(); if (decoded is null) { - return Attempt.Fail(UserOperationStatus.InvalidVerificationToken); + return Attempt.Fail(UserOperationStatus.InvalidInviteToken); } IUser? user = await GetAsync(userKey); @@ -1922,7 +2003,7 @@ internal class UserService : RepositoryService, IUserService return isValid ? Attempt.Succeed(UserOperationStatus.Success) - : Attempt.Fail(UserOperationStatus.InvalidVerificationToken); + : Attempt.Fail(UserOperationStatus.InvalidInviteToken); } public async Task> CreateInitialPasswordAsync(Guid userKey, string token, string password) @@ -1948,6 +2029,24 @@ internal class UserService : RepositoryService, IUserService return changePasswordAttempt; } + public async Task> ResetPasswordAsync(Guid userKey, string token, string password) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + Attempt changePasswordAttempt = + await ChangePasswordAsync(userKey, new ChangeUserPasswordModel + { + NewPassword = password, + UserKey = userKey, + ResetPasswordToken = token + }); + + scope.Complete(); + return changePasswordAttempt; + } + + + /// /// Removes a specific section from all users /// diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index e19a1f4d3a..39ae4cb2de 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -189,6 +189,7 @@ public static partial class UmbracoBuilderExtensions services.GetService>(), services.GetService>())); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs b/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs new file mode 100644 index 0000000000..6c276a21bb --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs @@ -0,0 +1,75 @@ +using System.Globalization; +using Microsoft.Extensions.Options; +using MimeKit; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Mail; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Email; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Security; + +public class EmailUserForgotPasswordSender : IUserForgotPasswordSender +{ + private readonly IEmailSender _emailSender; + private readonly ILocalizedTextService _localizedTextService; + private GlobalSettings _globalSettings; + private SecuritySettings _securitySettings; + + public EmailUserForgotPasswordSender( + IEmailSender emailSender, + ILocalizedTextService localizedTextService, + IOptionsMonitor globalSettings, + IOptionsMonitor securitySettings) + { + _emailSender = emailSender; + _localizedTextService = localizedTextService; + _globalSettings = globalSettings.CurrentValue; + _securitySettings = securitySettings.CurrentValue; + + globalSettings.OnChange(settings => _globalSettings = settings); + securitySettings.OnChange(settings => _securitySettings = settings); + } + + public async Task SendForgotPassword(UserForgotPasswordMessage messageModel) + { + CultureInfo recipientCulture = UmbracoUserExtensions.GetUserCulture( + messageModel.Recipient.Language, + _localizedTextService, + _globalSettings); + + string? senderEmail = _globalSettings.Smtp?.From; + + var emailSubject = _localizedTextService.Localize( + "login", + "resetPasswordEmailCopySubject", + recipientCulture); + + string?[] bodyTokes = + { + messageModel.Recipient.Username, + messageModel.ForgotPasswordUri.ToString(), + senderEmail, + }; + + string emailBody = _localizedTextService.Localize( + "login", + "resetPasswordEmailCopyFormat", + recipientCulture, + bodyTokes); + + // This needs to be in the correct mailto format including the name, else + // the name cannot be captured in the email sending notification. + // i.e. "Some Person" + var address = new MailboxAddress(messageModel.Recipient.Name, messageModel.Recipient.Email); + + var message = new EmailMessage(senderEmail, address.ToString(), emailSubject, emailBody, true); + + await _emailSender.SendAsync(message, Constants.Web.EmailTypes.PasswordReset, true); + } + + public bool CanSend() => _securitySettings.AllowPasswordReset && _emailSender.CanSendRequiredEmail(); +} diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index 7fd11abe06..dcab6871d6 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -62,6 +62,7 @@ public static partial class UmbracoBuilderExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); ; diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficePasswordChanger.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficePasswordChanger.cs index 3e6551e8ca..d4c3bfe83b 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficePasswordChanger.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficePasswordChanger.cs @@ -20,15 +20,16 @@ public class BackOfficePasswordChanger : IBackOfficePasswordChanger } public async Task> ChangeBackOfficePassword( - ChangeBackOfficeUserPasswordModel model) + ChangeBackOfficeUserPasswordModel model, IUser? performingUser) { var mappedModel = new ChangingPasswordModel { Id = model.User.Id, OldPassword = model.OldPassword, - NewPassword = model.NewPassword + NewPassword = model.NewPassword, + ResetPasswordToken = model.ResetPasswordToken, }; - return await _passwordChanger.ChangePasswordWithIdentityAsync(mappedModel, _userManager); + return await _passwordChanger.ChangePasswordWithIdentityAsync(mappedModel, _userManager, performingUser); } } diff --git a/src/Umbraco.Web.BackOffice/Security/ForgotPasswordUriProvider.cs b/src/Umbraco.Web.BackOffice/Security/ForgotPasswordUriProvider.cs new file mode 100644 index 0000000000..f44420672e --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Security/ForgotPasswordUriProvider.cs @@ -0,0 +1,59 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.BackOffice.Controllers; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.BackOffice.Security; + +public class ForgotPasswordUriProvider : IForgotPasswordUriProvider +{ + private readonly LinkGenerator _linkGenerator; + private readonly ICoreBackOfficeUserManager _userManager; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly WebRoutingSettings _webRoutingSettings; + + public ForgotPasswordUriProvider( + LinkGenerator linkGenerator, + ICoreBackOfficeUserManager userManager, + IHttpContextAccessor httpContextAccessor, + IOptions webRoutingSettings) + { + _linkGenerator = linkGenerator; + _userManager = userManager; + _httpContextAccessor = httpContextAccessor; + _webRoutingSettings = webRoutingSettings.Value; + } + + public async Task> CreateForgotPasswordUriAsync(IUser user) + { + Attempt tokenAttempt = await _userManager.GeneratePasswordResetTokenAsync(user); + + if (tokenAttempt.Success is false) + { + return Attempt.FailWithStatus(tokenAttempt.Status, new Uri(string.Empty)); + } + + string forgotPasswordToken = $"{user.Key}{WebUtility.UrlEncode("|")}{tokenAttempt.Result.ToUrlBase64()}"; + + // FIXME: This will need to change. + string? action = _linkGenerator.GetPathByAction( + nameof(BackOfficeController.ValidatePasswordResetCode), + ControllerExtensions.GetControllerName(), + new { area = Constants.Web.Mvc.BackOfficeArea, invite = forgotPasswordToken }); + + Uri applicationUri = _httpContextAccessor + .GetRequiredHttpContext() + .Request + .GetApplicationUri(_webRoutingSettings); + + var forgotPasswordUri = new Uri(applicationUri, action); + return Attempt.SucceedWithStatus(UserOperationStatus.Success, forgotPasswordUri); + } +} diff --git a/src/Umbraco.Web.BackOffice/Security/InviteUriProvider.cs b/src/Umbraco.Web.BackOffice/Security/InviteUriProvider.cs index 7bee6768dc..8226848604 100644 --- a/src/Umbraco.Web.BackOffice/Security/InviteUriProvider.cs +++ b/src/Umbraco.Web.BackOffice/Security/InviteUriProvider.cs @@ -40,7 +40,7 @@ public class InviteUriProvider : IInviteUriProvider return Attempt.FailWithStatus(tokenAttempt.Status, new Uri(string.Empty)); } - string inviteToken = $"{invitee.Id}{WebUtility.UrlEncode("|")}{tokenAttempt.Result.ToUrlBase64()}"; + string inviteToken = $"{invitee.Key}{WebUtility.UrlEncode("|")}{tokenAttempt.Result.ToUrlBase64()}"; // FIXME: This will need to change. string? action = _linkGenerator.GetPathByAction( diff --git a/src/Umbraco.Web.BackOffice/Security/PasswordChanger.cs b/src/Umbraco.Web.BackOffice/Security/PasswordChanger.cs index d9142dc667..dabd6578d8 100644 --- a/src/Umbraco.Web.BackOffice/Security/PasswordChanger.cs +++ b/src/Umbraco.Web.BackOffice/Security/PasswordChanger.cs @@ -67,23 +67,22 @@ internal class PasswordChanger : IPasswordChanger where TUser : Um }); } - // Are we just changing another user/member's password? + // If old password is not specified we either have to change another user's password, or provide a reset password token if (changingPasswordModel.OldPassword.IsNullOrWhiteSpace()) { - if (changingPasswordModel.Id == currentUser?.Id) + if (changingPasswordModel.Id == currentUser?.Id && changingPasswordModel.ResetPasswordToken is null) { return Attempt.Fail(new PasswordChangedModel { - Error = new ValidationResult("Cannot change the password of current user without the old password", new[] { "value" }), + Error = new ValidationResult("Cannot change the password of current user without the old password or a reset password token", new[] { "value" }), }); } // ok, we should be able to reset it - var resetToken = await userMgr.GeneratePasswordResetTokenAsync(identityUser); - - IdentityResult resetResult = - await userMgr.ChangePasswordWithResetAsync(userId, resetToken, changingPasswordModel.NewPassword); + IdentityResult resetResult = changingPasswordModel.ResetPasswordToken is not null + ? await userMgr.ResetPasswordAsync(identityUser, changingPasswordModel.ResetPasswordToken.FromUrlBase64()!, changingPasswordModel.NewPassword) + : await userMgr.ChangePasswordWithResetAsync(userId, await userMgr.GeneratePasswordResetTokenAsync(identityUser), changingPasswordModel.NewPassword); if (resetResult.Succeeded == false) { diff --git a/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs b/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs index ee0776be54..25002432af 100644 --- a/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.Security.Claims; using System.Security.Principal; using Microsoft.AspNetCore.Http; @@ -317,6 +318,19 @@ public class BackOfficeUserManager : UmbracoUserManager> GeneratePasswordResetTokenAsync(IUser user) + { + BackOfficeIdentityUser? identityUser = await FindByIdAsync(user.Id.ToString()); + + if (identityUser is null) + { + return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, string.Empty); + } + + var token = await GeneratePasswordResetTokenAsync(identityUser); + + return Attempt.SucceedWithStatus(UserOperationStatus.Success, token); + } public async Task> GenerateEmailConfirmationTokenAsync(IUser user) { BackOfficeIdentityUser? identityUser = await FindByIdAsync(user.Id.ToString()); @@ -344,7 +358,7 @@ public class BackOfficeUserManager : UmbracoUserManager IsEmailConfirmationTokenValidAsync(IUser user, string token) { - BackOfficeIdentityUser? identityUser = await FindByIdAsync(user.Id.ToString()); + BackOfficeIdentityUser? identityUser = await FindByIdAsync(user.Id.ToString(CultureInfo.InvariantCulture)); if (identityUser != null && await VerifyUserTokenAsync(identityUser, Options.Tokens.EmailConfirmationTokenProvider, ConfirmEmailTokenPurpose, token).ConfigureAwait(false)) { @@ -353,4 +367,16 @@ public class BackOfficeUserManager : UmbracoUserManager IsResetPasswordTokenValidAsync(IUser user, string token) + { + BackOfficeIdentityUser? identityUser = await FindByIdAsync(user.Id.ToString(CultureInfo.InvariantCulture)); + + if (identityUser != null && await VerifyUserTokenAsync(identityUser, Options.Tokens.PasswordResetTokenProvider, ResetPasswordTokenPurpose, token).ConfigureAwait(false)) + { + return true; + } + + return false; + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.cs index 77c3b55e58..c8bcb16ca9 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.cs @@ -81,7 +81,8 @@ public partial class UserServiceCrudTests : UmbracoIntegrationTest GetRequiredService(), GetRequiredService(), GetRequiredService>(), - GetRequiredService()); + GetRequiredService(), + GetRequiredService()); }