diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaForgotPasswordFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaForgotPasswordFormDisplayDriver.cs new file mode 100644 index 00000000000..f3f6943ca40 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaForgotPasswordFormDisplayDriver.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.ReCaptcha.Configuration; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.ReCaptcha.Drivers; + +public sealed class ReCaptchaForgotPasswordFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + + public ReCaptchaForgotPasswordFormDisplayDriver(ISiteService siteService) + { + _siteService = siteService; + } + + public override async Task EditAsync(ForgotPasswordForm model, BuildEditorContext context) + { + var _reCaptchaSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!_reCaptchaSettings.IsValid()) + { + return null; + } + + return View("FormReCaptcha", model).Location("Content:after"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaLoginFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaLoginFormDisplayDriver.cs index 8b2432b7941..08be62e8225 100644 --- a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaLoginFormDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaLoginFormDisplayDriver.cs @@ -8,7 +8,7 @@ namespace OrchardCore.ReCaptcha.Drivers; -public class ReCaptchaLoginFormDisplayDriver : DisplayDriver +public sealed class ReCaptchaLoginFormDisplayDriver : DisplayDriver { private readonly ISiteService _siteService; private readonly ReCaptchaService _reCaptchaService; @@ -30,6 +30,6 @@ public override async Task EditAsync(LoginForm model, BuildEdito return null; } - return View("LoginFormReCaptcha_Edit", model).Location("Content:after"); + return View("FormReCaptcha", model).Location("Content:after"); } } diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaResetPasswordFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaResetPasswordFormDisplayDriver.cs new file mode 100644 index 00000000000..010fe0cba4a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaResetPasswordFormDisplayDriver.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.ReCaptcha.Configuration; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.ReCaptcha.Drivers; + +public sealed class ReCaptchaResetPasswordFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + + public ReCaptchaResetPasswordFormDisplayDriver(ISiteService siteService) + { + _siteService = siteService; + } + + public override async Task EditAsync(ResetPasswordForm model, BuildEditorContext context) + { + var _reCaptchaSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!_reCaptchaSettings.IsValid()) + { + return null; + } + + return View("FormReCaptcha", model).Location("Content:after"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/RegisterUserFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/RegisterUserFormDisplayDriver.cs new file mode 100644 index 00000000000..2ab32c34563 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/RegisterUserFormDisplayDriver.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.ReCaptcha.Configuration; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.ReCaptcha.Drivers; + +public sealed class RegisterUserFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + + public RegisterUserFormDisplayDriver(ISiteService siteService) + { + _siteService = siteService; + } + + public override async Task EditAsync(RegisterUserForm model, BuildEditorContext context) + { + var _reCaptchaSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!_reCaptchaSettings.IsValid()) + { + return null; + } + + return View("FormReCaptcha", model).Location("Content:after"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/ReCaptchaLoginFilter.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/ReCaptchaLoginFilter.cs deleted file mode 100644 index 411d0e19e1f..00000000000 --- a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/ReCaptchaLoginFilter.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Filters; -using OrchardCore.DisplayManagement; -using OrchardCore.DisplayManagement.Layout; -using OrchardCore.ReCaptcha.Configuration; -using OrchardCore.ReCaptcha.Services; -using OrchardCore.Settings; - -namespace OrchardCore.ReCaptcha -{ - public class ReCaptchaLoginFilter : IAsyncResultFilter - { - private readonly ILayoutAccessor _layoutAccessor; - private readonly ISiteService _siteService; - private readonly ReCaptchaService _reCaptchaService; - private readonly IShapeFactory _shapeFactory; - - private ReCaptchaSettings _reCaptchaSettings; - - public ReCaptchaLoginFilter( - ILayoutAccessor layoutAccessor, - ISiteService siteService, - ReCaptchaService reCaptchaService, - IShapeFactory shapeFactory) - { - _layoutAccessor = layoutAccessor; - _siteService = siteService; - _reCaptchaService = reCaptchaService; - _shapeFactory = shapeFactory; - } - - public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) - { - if (!context.IsViewOrPageResult() - || !string.Equals("OrchardCore.Users", Convert.ToString(context.RouteData.Values["area"]), StringComparison.OrdinalIgnoreCase)) - { - await next(); - return; - } - - _reCaptchaSettings ??= (await _siteService.GetSiteSettingsAsync()).As(); - - if (!_reCaptchaSettings.IsValid()) - { - await next(); - return; - } - - var layout = await _layoutAccessor.GetLayoutAsync(); - - var afterForgotPasswordZone = layout.Zones["AfterForgotPassword"]; - await afterForgotPasswordZone.AddAsync(await _shapeFactory.CreateAsync("ReCaptcha")); - - var afterRegisterZone = layout.Zones["AfterRegister"]; - await afterRegisterZone.AddAsync(await _shapeFactory.CreateAsync("ReCaptcha")); - - var afterResetPasswordZone = layout.Zones["AfterResetPassword"]; - await afterResetPasswordZone.AddAsync(await _shapeFactory.CreateAsync("ReCaptcha")); - - await next(); - } - } -} diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs index b1d37986d92..32274a3b8e1 100644 --- a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.Modules; @@ -10,13 +9,14 @@ using OrchardCore.Security.Permissions; using OrchardCore.Settings; using OrchardCore.Settings.Deployment; +using OrchardCore.Users; using OrchardCore.Users.Events; using OrchardCore.Users.Models; namespace OrchardCore.ReCaptcha { [Feature("OrchardCore.ReCaptcha")] - public class Startup : StartupBase + public sealed class Startup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -30,7 +30,7 @@ public override void ConfigureServices(IServiceCollection services) [Feature("OrchardCore.ReCaptcha")] [RequireFeatures("OrchardCore.Deployment")] - public class DeploymentStartup : StartupBase + public sealed class DeploymentStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -39,7 +39,7 @@ public override void ConfigureServices(IServiceCollection services) } [Feature("OrchardCore.ReCaptcha.Users")] - public class StartupUsers : StartupBase + public sealed class UsersStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -47,10 +47,27 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped, ReCaptchaLoginFormDisplayDriver>(); - services.Configure((options) => - { - options.Filters.Add(); - }); + } + } + + [Feature("OrchardCore.ReCaptcha.Users")] + [RequireFeatures(UserConstants.Features.ResetPassword)] + public sealed class UsersResetPasswordStartup : StartupBase + { + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped, ReCaptchaForgotPasswordFormDisplayDriver>(); + services.AddScoped, ReCaptchaResetPasswordFormDisplayDriver>(); + } + } + + [Feature("OrchardCore.ReCaptcha.Users")] + [RequireFeatures(UserConstants.Features.UserRegistration)] + public sealed class UsersRegistrationStartup : StartupBase + { + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped, RegisterUserFormDisplayDriver>(); } } } diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Views/LoginFormReCaptcha.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Views/FormReCaptcha.cshtml similarity index 100% rename from src/OrchardCore.Modules/OrchardCore.ReCaptcha/Views/LoginFormReCaptcha.Edit.cshtml rename to src/OrchardCore.Modules/OrchardCore.ReCaptcha/Views/FormReCaptcha.cshtml diff --git a/src/OrchardCore.Modules/OrchardCore.Users/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Users/AdminMenu.cs index 57669d887cd..03d6e4a9ce5 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/AdminMenu.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/AdminMenu.cs @@ -131,7 +131,7 @@ public Task BuildNavigationAsync(string name, NavigationBuilder builder) } } - [Feature("OrchardCore.Users.ResetPassword")] + [Feature(UserConstants.Features.ResetPassword)] public class ResetPasswordAdminMenu : INavigationProvider { private static readonly RouteValueDictionary _routeValues = new() diff --git a/src/OrchardCore.Modules/OrchardCore.Users/AuditTrail/ResetPassword/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Users/AuditTrail/ResetPassword/Startup.cs index fa59530f214..013935cc966 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/AuditTrail/ResetPassword/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/AuditTrail/ResetPassword/Startup.cs @@ -6,7 +6,7 @@ namespace OrchardCore.Users.AuditTrail.ResetPassword { - [RequireFeatures("OrchardCore.Users.AuditTrail", "OrchardCore.Users.ResetPassword")] + [RequireFeatures("OrchardCore.Users.AuditTrail", UserConstants.Features.ResetPassword)] public class Startup : StartupBase { public override void ConfigureServices(IServiceCollection services) diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs index b872fae1948..3e88f66dc4e 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs @@ -442,12 +442,11 @@ public async Task ExternalLoginCallback(string returnUrl = null, if (noInformationRequired) { - iUser = await this.RegisterUser(new RegisterViewModel() + iUser = await this.RegisterUser(new RegisterUserForm() { UserName = externalLoginViewModel.UserName, Email = externalLoginViewModel.Email, Password = null, - ConfirmPassword = null }, S["Confirm your account"], _logger); // If the registration was successful we can link the external provider and redirect the user. @@ -559,12 +558,11 @@ public async Task RegisterExternalLogin(RegisterExternalLoginView if (TryValidateModel(model) && ModelState.IsValid) { var iUser = await this.RegisterUser( - new RegisterViewModel() + new RegisterUserForm() { UserName = model.UserName, Email = model.Email, Password = model.Password, - ConfirmPassword = model.ConfirmPassword }, S["Confirm your account"], _logger); if (iUser is null) diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AdminController.cs index ada0d461b7a..59e92d28eef 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AdminController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AdminController.cs @@ -499,7 +499,7 @@ public async Task EditPassword(string id) return Forbid(); } - var model = new ResetPasswordViewModel { Email = user.Email }; + var model = new ResetPasswordViewModel { Identifier = user.UserName }; return View(model); } @@ -507,7 +507,7 @@ public async Task EditPassword(string id) [HttpPost] public async Task EditPassword(ResetPasswordViewModel model) { - if (await _userManager.FindByEmailAsync(model.Email) is not User user) + if (await _userService.GetUserAsync(model.Identifier) is not User user) { return NotFound(); } @@ -521,7 +521,7 @@ public async Task EditPassword(ResetPasswordViewModel model) { var token = await _userManager.GeneratePasswordResetTokenAsync(user); - if (await _userService.ResetPasswordAsync(model.Email, token, model.NewPassword, ModelState.AddModelError)) + if (await _userService.ResetPasswordAsync(model.Identifier, token, model.NewPassword, ModelState.AddModelError)) { await _notifier.SuccessAsync(H["Password updated correctly."]); diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ControllerExtensions.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ControllerExtensions.cs index f0842fdf453..0e0ce35e838 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ControllerExtensions.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ControllerExtensions.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; using OrchardCore.DisplayManagement; using OrchardCore.Email; +using OrchardCore.Environment.Shell; using OrchardCore.Modules; using OrchardCore.Settings; using OrchardCore.Users.Events; @@ -55,20 +56,37 @@ internal static async Task SendEmailAsync(this Controller controller, stri /// /// /// - internal static async Task RegisterUser(this Controller controller, RegisterViewModel model, string confirmationEmailSubject, ILogger logger) + internal static async Task RegisterUser(this Controller controller, RegisterUserForm model, string confirmationEmailSubject, ILogger logger) { - var registrationEvents = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService>(); - var userService = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService(); + var shellFeaturesManager = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService(); + + var registrationFeatureIsAvailable = (await shellFeaturesManager.GetAvailableFeaturesAsync()) + .Any(feature => feature.Id == UserConstants.Features.UserRegistration); + + if (!registrationFeatureIsAvailable) + { + return null; + } + var settings = (await controller.ControllerContext.HttpContext.RequestServices.GetRequiredService().GetSiteSettingsAsync()).As(); - var signInManager = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService>(); if (settings.UsersCanRegister != UserRegistrationType.NoRegistration) { + var registrationEvents = controller.ControllerContext.HttpContext.RequestServices.GetServices(); + await registrationEvents.InvokeAsync((e, modelState) => e.RegistrationValidationAsync((key, message) => modelState.AddModelError(key, message)), controller.ModelState, logger); if (controller.ModelState.IsValid) { - var user = await userService.CreateUserAsync(new User { UserName = model.UserName, Email = model.Email, EmailConfirmed = !settings.UsersMustValidateEmail, IsEnabled = !settings.UsersAreModerated }, model.Password, (key, message) => controller.ModelState.AddModelError(key, message)) as User; + var userService = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService(); + + var user = await userService.CreateUserAsync(new User + { + UserName = model.UserName, + Email = model.Email, + EmailConfirmed = !settings.UsersMustValidateEmail, + IsEnabled = !settings.UsersAreModerated + }, model.Password, controller.ModelState.AddModelError) as User; if (user != null && controller.ModelState.IsValid) { @@ -80,6 +98,8 @@ internal static async Task RegisterUser(this Controller controller, Regis } else if (!(settings.UsersAreModerated && !user.IsEnabled)) { + var signInManager = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService>(); + await signInManager.SignInAsync(user, isPersistent: false); } logger.LogInformation(3, "User created a new account with password."); @@ -89,6 +109,7 @@ internal static async Task RegisterUser(this Controller controller, Regis } } } + return null; } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/RegistrationController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/RegistrationController.cs index abc689ea934..ba47f420407 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/RegistrationController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/RegistrationController.cs @@ -6,11 +6,12 @@ using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.DisplayManagement.Notify; using OrchardCore.Modules; using OrchardCore.Settings; using OrchardCore.Users.Models; -using OrchardCore.Users.ViewModels; namespace OrchardCore.Users.Controllers { @@ -22,6 +23,9 @@ public class RegistrationController : Controller private readonly ISiteService _siteService; private readonly INotifier _notifier; private readonly ILogger _logger; + private readonly IDisplayManager _registerUserDisplayManager; + private readonly IUpdateModelAccessor _updateModelAccessor; + protected readonly IStringLocalizer S; protected readonly IHtmlLocalizer H; @@ -31,6 +35,8 @@ public RegistrationController( ISiteService siteService, INotifier notifier, ILogger logger, + IDisplayManager registerUserDisplayManager, + IUpdateModelAccessor updateModelAccessor, IHtmlLocalizer htmlLocalizer, IStringLocalizer stringLocalizer) { @@ -39,11 +45,12 @@ public RegistrationController( _siteService = siteService; _notifier = notifier; _logger = logger; + _registerUserDisplayManager = registerUserDisplayManager; + _updateModelAccessor = updateModelAccessor; H = htmlLocalizer; S = stringLocalizer; } - [HttpGet] [AllowAnonymous] public async Task Register(string returnUrl = null) { @@ -53,14 +60,18 @@ public async Task Register(string returnUrl = null) return NotFound(); } + var shape = await _registerUserDisplayManager.BuildEditorAsync(_updateModelAccessor.ModelUpdater, false, string.Empty, string.Empty); + ViewData["ReturnUrl"] = returnUrl; - return View(); + + return View(shape); } [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] - public async Task Register(RegisterViewModel model, string returnUrl = null) + [ActionName(nameof(Register))] + public async Task RegisterPOST(string returnUrl = null) { var settings = (await _siteService.GetSiteSettingsAsync()).As(); @@ -69,45 +80,35 @@ public async Task Register(RegisterViewModel model, string return return NotFound(); } - if (string.IsNullOrEmpty(model.Email)) - { - ModelState.AddModelError("Email", S["Email is required."]); - } - - if (ModelState.IsValid) - { - // Check if user with same email already exists - var userWithEmail = await _userManager.FindByEmailAsync(model.Email); + var model = new RegisterUserForm(); - if (userWithEmail != null) - { - ModelState.AddModelError("Email", S["A user with the same email already exists."]); - } - } + var shape = await _registerUserDisplayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, false, string.Empty, string.Empty); ViewData["ReturnUrl"] = returnUrl; if (ModelState.IsValid) { var iUser = await this.RegisterUser(model, S["Confirm your account"], _logger); + // If we get a user, redirect to returnUrl if (iUser is User user) { if (settings.UsersMustValidateEmail && !user.EmailConfirmed) { - return RedirectToAction("ConfirmEmailSent", new { ReturnUrl = returnUrl }); + return RedirectToAction(nameof(ConfirmEmailSent), new { ReturnUrl = returnUrl }); } + if (settings.UsersAreModerated && !user.IsEnabled) { - return RedirectToAction("RegistrationPending", new { ReturnUrl = returnUrl }); + return RedirectToAction(nameof(RegistrationPending), new { ReturnUrl = returnUrl }); } return RedirectToLocal(returnUrl.ToUriComponents()); } } - // If we got this far, something failed, redisplay form - return View(model); + // If we got this far, something failed. Let's redisplay form. + return View(shape); } [HttpGet] @@ -116,7 +117,7 @@ public async Task ConfirmEmail(string userId, string code) { if (userId == null || code == null) { - return RedirectToAction(nameof(RegistrationController.Register), "Registration"); + return RedirectToAction(nameof(Register)); } var user = await _userManager.FindByIdAsync(userId); diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ResetPasswordController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ResetPasswordController.cs index 31cb64985a1..298ab953f3e 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ResetPasswordController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ResetPasswordController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; @@ -7,7 +8,11 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.Environment.Shell; using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; using OrchardCore.Settings; using OrchardCore.Users.Events; using OrchardCore.Users.Models; @@ -16,34 +21,47 @@ namespace OrchardCore.Users.Controllers { - [Feature("OrchardCore.Users.ResetPassword")] + [Feature(UserConstants.Features.ResetPassword)] public class ResetPasswordController : Controller { + private static readonly string _controllerName = typeof(ResetPasswordController).ControllerName(); + private readonly IUserService _userService; private readonly UserManager _userManager; private readonly ISiteService _siteService; private readonly IEnumerable _passwordRecoveryFormEvents; private readonly ILogger _logger; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IDisplayManager _forgotPasswordDisplayManager; + private readonly IDisplayManager _resetPasswordDisplayManager; + private readonly IShellFeaturesManager _shellFeaturesManager; + protected readonly IStringLocalizer S; public ResetPasswordController( IUserService userService, UserManager userManager, ISiteService siteService, - IStringLocalizer stringLocalizer, ILogger logger, - IEnumerable passwordRecoveryFormEvents) + IUpdateModelAccessor updateModelAccessor, + IDisplayManager forgotPasswordDisplayManager, + IDisplayManager resetPasswordDisplayManager, + IShellFeaturesManager shellFeaturesManager, + IEnumerable passwordRecoveryFormEvents, + IStringLocalizer stringLocalizer) { _userService = userService; _userManager = userManager; _siteService = siteService; - - S = stringLocalizer; _logger = logger; + _updateModelAccessor = updateModelAccessor; + _forgotPasswordDisplayManager = forgotPasswordDisplayManager; + _resetPasswordDisplayManager = resetPasswordDisplayManager; + _shellFeaturesManager = shellFeaturesManager; _passwordRecoveryFormEvents = passwordRecoveryFormEvents; + S = stringLocalizer; } - [HttpGet] [AllowAnonymous] public async Task ForgotPassword() { @@ -52,56 +70,63 @@ public async Task ForgotPassword() return NotFound(); } - return View(); + var formShape = await _forgotPasswordDisplayManager.BuildEditorAsync(_updateModelAccessor.ModelUpdater, false, string.Empty, string.Empty); + + return View(formShape); } [HttpPost] [AllowAnonymous] - public async Task ForgotPassword(ForgotPasswordViewModel model) + [ActionName(nameof(ForgotPassword))] + public async Task ForgotPasswordPOST() { if (!(await _siteService.GetSiteSettingsAsync()).As().AllowResetPassword) { return NotFound(); } + var model = new ForgotPasswordForm(); + + var formShape = await _forgotPasswordDisplayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, false, string.Empty, string.Empty); + await _passwordRecoveryFormEvents.InvokeAsync((e, modelState) => e.RecoveringPasswordAsync((key, message) => modelState.AddModelError(key, message)), ModelState, _logger); - if (TryValidateModel(model) && ModelState.IsValid) + if (ModelState.IsValid) { - var user = await _userService.GetForgotPasswordUserAsync(model.Email) as User; - if (user == null || ( - (await _siteService.GetSiteSettingsAsync()).As().UsersMustValidateEmail - && !await _userManager.IsEmailConfirmedAsync(user)) - ) + var user = await _userService.GetForgotPasswordUserAsync(model.Identifier) as User; + if (user == null || await MustValidateEmailAsync(user)) { // returns to confirmation page anyway: we don't want to let scrapers know if a username or an email exist - return RedirectToLocal(Url.Action("ForgotPasswordConfirmation", "ResetPassword")); + return RedirectToAction(nameof(ForgotPasswordConfirmation)); } user.ResetToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(user.ResetToken)); - var resetPasswordUrl = Url.Action("ResetPassword", "ResetPassword", new { code = user.ResetToken }, HttpContext.Request.Scheme); + var resetPasswordUrl = Url.Action(nameof(ResetPassword), _controllerName, new { code = user.ResetToken }, HttpContext.Request.Scheme); + // send email with callback link - await this.SendEmailAsync(user.Email, S["Reset your password"], new LostPasswordViewModel() { User = user, LostPasswordUrl = resetPasswordUrl }); + await this.SendEmailAsync(user.Email, S["Reset your password"], new LostPasswordViewModel() + { + User = user, + LostPasswordUrl = resetPasswordUrl + }); var context = new PasswordRecoveryContext(user); await _passwordRecoveryFormEvents.InvokeAsync((handler, context) => handler.PasswordRecoveredAsync(context), context, _logger); - return RedirectToLocal(Url.Action("ForgotPasswordConfirmation", "ResetPassword")); + return RedirectToAction(nameof(ForgotPasswordConfirmation)); } // If we got this far, something failed, redisplay form - return View(model); + return View(formShape); } - [HttpGet] [AllowAnonymous] public IActionResult ForgotPasswordConfirmation() { return View(); } - [HttpGet] [AllowAnonymous] public async Task ResetPassword(string code = null) { @@ -109,51 +134,65 @@ public async Task ResetPassword(string code = null) { return NotFound(); } - if (code == null) + + if (string.IsNullOrWhiteSpace(code)) { - // "A code must be supplied for password reset."; + return RedirectToAction(nameof(ForgotPassword)); } - return View(new ResetPasswordViewModel { ResetToken = code }); + + var model = new ResetPasswordForm { ResetToken = code }; + var shape = await _resetPasswordDisplayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, false, string.Empty, string.Empty); + + return View(shape); } [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] - public async Task ResetPassword(ResetPasswordViewModel model) + [ActionName(nameof(ResetPassword))] + public async Task ResetPasswordPOST() { if (!(await _siteService.GetSiteSettingsAsync()).As().AllowResetPassword) { return NotFound(); } + var model = new ResetPasswordForm(); + var shape = await _resetPasswordDisplayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, false, string.Empty, string.Empty); + await _passwordRecoveryFormEvents.InvokeAsync((e, modelState) => e.ResettingPasswordAsync((key, message) => modelState.AddModelError(key, message)), ModelState, _logger); - if (TryValidateModel(model) && ModelState.IsValid) + if (ModelState.IsValid) { - if (await _userService.ResetPasswordAsync(model.Email, Encoding.UTF8.GetString(Convert.FromBase64String(model.ResetToken)), model.NewPassword, (key, message) => ModelState.AddModelError(key == "Password" ? nameof(ResetPasswordViewModel.NewPassword) : key, message))) + var token = Encoding.UTF8.GetString(Convert.FromBase64String(model.ResetToken)); + + if (await _userService.ResetPasswordAsync(model.Identifier, token, model.NewPassword, ModelState.AddModelError)) { - return RedirectToLocal(Url.Action("ResetPasswordConfirmation", "ResetPassword")); + return RedirectToAction(nameof(ResetPasswordConfirmation)); } } - return View(model); + return View(shape); } - [HttpGet] [AllowAnonymous] public IActionResult ResetPasswordConfirmation() { return View(); } - private RedirectResult RedirectToLocal(string returnUrl) + private async Task MustValidateEmailAsync(User user) { - if (Url.IsLocalUrl(returnUrl)) + var registrationFeatureIsAvailable = (await _shellFeaturesManager.GetAvailableFeaturesAsync()) + .Any(feature => feature.Id == UserConstants.Features.UserRegistration); + + if (!registrationFeatureIsAvailable) { - return Redirect(returnUrl); + return false; } - return Redirect("~/"); + return (await _siteService.GetSiteSettingsAsync()).As().UsersMustValidateEmail + && !await _userManager.IsEmailConfirmedAsync(user); } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordFormDisplayDriver.cs new file mode 100644 index 00000000000..cc087b1efb7 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordFormDisplayDriver.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Users.Models; +using OrchardCore.Users.ViewModels; + +namespace OrchardCore.Users.Drivers; + +public sealed class ForgotPasswordFormDisplayDriver : DisplayDriver +{ + public override IDisplayResult Edit(ForgotPasswordForm model) + { + return Initialize("ForgotPasswordFormIdentifier", vm => + { + vm.Identifier = model.Identifier; + }).Location("Content"); + } + + public override async Task UpdateAsync(ForgotPasswordForm model, IUpdateModel updater) + { + var viewModel = new ForgotPasswordViewModel(); + + await updater.TryUpdateModelAsync(viewModel, Prefix); + + model.Identifier = viewModel.Identifier; + + return Edit(model); + } +} + diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordLoginFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordLoginFormDisplayDriver.cs index 65c301d5993..142225ebf7a 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordLoginFormDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordLoginFormDisplayDriver.cs @@ -6,7 +6,7 @@ namespace OrchardCore.Users.Drivers; -public class ForgotPasswordLoginFormDisplayDriver : DisplayDriver +public sealed class ForgotPasswordLoginFormDisplayDriver : DisplayDriver { private readonly ISiteService _siteService; @@ -24,6 +24,6 @@ public override async Task EditAsync(LoginForm model, BuildEdito return null; } - return View("LoginFormForgotPassword_Edit", model).Location("Links:5"); + return View("LoginFormForgotPassword", model).Location("Links:5"); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginFormDisplayDriver.cs index b7997dc2588..23c68c647e5 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginFormDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginFormDisplayDriver.cs @@ -2,17 +2,16 @@ using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.DisplayManagement.Views; -using OrchardCore.Entities; using OrchardCore.Users.Models; using OrchardCore.Users.ViewModels; namespace OrchardCore.Users.Drivers; -public class LoginFormDisplayDriver : DisplayDriver +public sealed class LoginFormDisplayDriver : DisplayDriver { public override IDisplayResult Edit(LoginForm model) { - return Initialize("LoginFormCredentials_Edit", vm => + return Initialize("LoginFormCredentials", vm => { vm.UserName = model.UserName; vm.RememberMe = model.RememberMe; @@ -29,8 +28,6 @@ public override async Task UpdateAsync(LoginForm model, IUpdateM model.Password = viewModel.Password; model.RememberMe = viewModel.RememberMe; - model.Put(viewModel); - return Edit(model); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserFormDisplayDriver.cs new file mode 100644 index 00000000000..b506e16781d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserFormDisplayDriver.cs @@ -0,0 +1,62 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Users.Models; +using OrchardCore.Users.ViewModels; + +namespace OrchardCore.Users.Drivers; + +public sealed class RegisterUserFormDisplayDriver : DisplayDriver +{ + private readonly UserManager _userManager; + private readonly IdentityOptions _identityOptions; + + private readonly IStringLocalizer S; + + public RegisterUserFormDisplayDriver( + UserManager userManager, + IOptions identityOptions, + IStringLocalizer stringLocalizer + ) + { + _userManager = userManager; + _identityOptions = identityOptions.Value; + S = stringLocalizer; + } + + public override IDisplayResult Edit(RegisterUserForm model) + { + return Initialize("RegisterUserFormIdentifier", vm => + { + vm.UserName = model.UserName; + vm.Email = model.Email; + }).Location("Content"); + } + + public override async Task UpdateAsync(RegisterUserForm model, IUpdateModel updater) + { + var vm = new RegisterViewModel(); + + await updater.TryUpdateModelAsync(vm, Prefix); + + if (await _userManager.FindByNameAsync(vm.UserName) != null) + { + updater.ModelState.AddModelError(Prefix, nameof(vm.UserName), S["A user with the same username already exists."]); + } + else if (_identityOptions.User.RequireUniqueEmail && await _userManager.FindByEmailAsync(vm.Email) != null) + { + updater.ModelState.AddModelError(Prefix, nameof(vm.Email), S["A user with the same email address already exists."]); + } + + model.UserName = vm.UserName; + model.Email = vm.Email; + model.Password = vm.Password; + + return Edit(model); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs index 65311217fd8..707d406a26a 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs @@ -6,7 +6,7 @@ namespace OrchardCore.Users.Drivers; -public class RegisterUserLoginFormDisplayDriver : DisplayDriver +public sealed class RegisterUserLoginFormDisplayDriver : DisplayDriver { private readonly ISiteService _siteService; @@ -24,6 +24,6 @@ public override async Task EditAsync(LoginForm model, BuildEdito return null; } - return View("LoginFormRegisterUser_Edit", model).Location("Links:10"); + return View("LoginFormRegisterUser", model).Location("Links:10"); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ResetPasswordFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ResetPasswordFormDisplayDriver.cs new file mode 100644 index 00000000000..d188e00c29b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ResetPasswordFormDisplayDriver.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Users.Models; +using OrchardCore.Users.ViewModels; + +namespace OrchardCore.Users.Drivers; + +public sealed class ResetPasswordFormDisplayDriver : DisplayDriver +{ + public override IDisplayResult Edit(ResetPasswordForm model) + { + return Initialize("ResetPasswordFormIdentifier", vm => + { + vm.Identifier = model.Identifier; + vm.NewPassword = model.NewPassword; + vm.ResetToken = model.ResetToken; + }).Location("Content"); + } + + public override async Task UpdateAsync(ResetPasswordForm model, IUpdateModel updater) + { + var vm = new ResetPasswordViewModel(); + + await updater.TryUpdateModelAsync(vm, Prefix); + + model.Identifier = vm.Identifier; + model.NewPassword = vm.NewPassword; + model.ResetToken = vm.ResetToken; + + return Edit(model); + } +} + diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ResetPasswordSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ResetPasswordSettingsDisplayDriver.cs index 805e3695f68..9e82c7a46ed 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ResetPasswordSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ResetPasswordSettingsDisplayDriver.cs @@ -11,7 +11,7 @@ namespace OrchardCore.Users.Drivers { - [Feature("OrchardCore.Users.ResetPassword")] + [Feature(UserConstants.Features.ResetPassword)] public class ResetPasswordSettingsDisplayDriver : SectionDisplayDriver { public const string GroupId = "userResetPassword"; diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs index e3660a2a5bc..9d8b0f00be8 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs @@ -53,7 +53,7 @@ )] [assembly: Feature( - Id = "OrchardCore.Users.ResetPassword", + Id = UserConstants.Features.ResetPassword, Name = "Users Reset Password", Description = "The reset password feature allows users to reset their password.", Dependencies = diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Models/IdentitySettings.cs b/src/OrchardCore.Modules/OrchardCore.Users/Models/IdentitySettings.cs new file mode 100644 index 00000000000..1fca301dcb7 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Models/IdentitySettings.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; + +namespace OrchardCore.Users.Models; + +public class IdentitySettings +{ + public IdentityUserSettings UserSettings { get; set; } +} + +public class IdentityUserSettings +{ + public const string DefaultAllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._+"; + + /// + /// The list of allowed characters in the username used to validate user names. + /// + public string AllowedUserNameCharacters { get; set; } = DefaultAllowedUserNameCharacters; + + /// + /// Gets or sets a flag indicating whether the application requires unique emails + /// for its users. Defaults to false. + /// + [DefaultValue(true)] + public bool RequireUniqueEmail { get; set; } = true; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Services/IdentityOptionsConfigurations.cs b/src/OrchardCore.Modules/OrchardCore.Users/Services/IdentityOptionsConfigurations.cs new file mode 100644 index 00000000000..dc807379aa8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Services/IdentityOptionsConfigurations.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.Services; + +public sealed class IdentityOptionsConfigurations : IConfigureOptions +{ + private readonly ISiteService _siteService; + + public IdentityOptionsConfigurations(ISiteService siteService) + { + _siteService = siteService; + } + + public void Configure(IdentityOptions options) + { + var site = _siteService.GetSiteSettingsAsync().GetAwaiter().GetResult(); + + var settings = site.As(); + + if (!string.IsNullOrEmpty(settings.UserSettings?.AllowedUserNameCharacters)) + { + options.User.AllowedUserNameCharacters = settings.UserSettings.AllowedUserNameCharacters; + } + else + { + options.User.AllowedUserNameCharacters = IdentityUserSettings.DefaultAllowedUserNameCharacters; + } + + // By default, we require a unique email for every user. + options.User.RequireUniqueEmail = settings.UserSettings?.RequireUniqueEmail ?? true; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs index 877136cb44f..21166067722 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs @@ -53,7 +53,7 @@ namespace OrchardCore.Users { - public class Startup : StartupBase + public sealed class Startup : StartupBase { private static readonly string _accountControllerName = typeof(AccountController).ControllerName(); @@ -120,15 +120,8 @@ public override void ConfigureServices(IServiceCollection services) // Add the default token providers used to generate tokens for reset passwords, change email, // and for two-factor authentication token generation. - services.AddIdentity(options => - { - // Specify OrchardCore User requirements. - // A user name cannot include an @ symbol, i.e. be an email address - // An email address must be provided, and be unique. - options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._+"; - options.User.RequireUniqueEmail = true; - }); - + services.AddIdentity(); + services.AddTransient, IdentityOptionsConfigurations>(); services.AddPhoneFormatValidator(); // Configure the authentication options to use the application cookie scheme as the default sign-out handler. // This is required for security modules like the OpenID module (that uses SignOutAsync()) to work correctly. @@ -207,7 +200,7 @@ public override void ConfigureServices(IServiceCollection services) } [RequireFeatures("OrchardCore.Roles")] - public class RolesStartup : StartupBase + public sealed class RolesStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -221,7 +214,7 @@ public override void ConfigureServices(IServiceCollection services) } [RequireFeatures("OrchardCore.Liquid")] - public class LiquidStartup : StartupBase + public sealed class LiquidStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -274,7 +267,7 @@ public override void ConfigureServices(IServiceCollection services) } [RequireFeatures("OrchardCore.Deployment")] - public class LoginDeploymentStartup : StartupBase + public sealed class LoginDeploymentStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -283,7 +276,7 @@ public override void ConfigureServices(IServiceCollection services) } [Feature(UserConstants.Features.UserEmailConfirmation)] - public class EmailConfirmationStartup : StartupBase + public sealed class EmailConfirmationStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -294,7 +287,7 @@ public override void ConfigureServices(IServiceCollection services) } [Feature("OrchardCore.Users.ChangeEmail")] - public class ChangeEmailStartup : StartupBase + public sealed class ChangeEmailStartup : StartupBase { private const string ChangeEmailPath = "ChangeEmail"; private const string ChangeEmailConfirmationPath = "ChangeEmailConfirmation"; @@ -336,7 +329,7 @@ public override void ConfigureServices(IServiceCollection services) [Feature("OrchardCore.Users.ChangeEmail")] [RequireFeatures("OrchardCore.Deployment")] - public class ChangeEmailDeploymentStartup : StartupBase + public sealed class ChangeEmailDeploymentStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -345,7 +338,7 @@ public override void ConfigureServices(IServiceCollection services) } [Feature(UserConstants.Features.UserRegistration)] - public class RegistrationStartup : StartupBase + public sealed class RegistrationStartup : StartupBase { private const string RegisterPath = nameof(RegistrationController.Register); private const string ConfirmEmailSent = nameof(RegistrationController.ConfirmEmailSent); @@ -386,12 +379,13 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped, RegistrationSettingsDisplayDriver>(); services.AddScoped, RegisterUserLoginFormDisplayDriver>(); + services.AddScoped, RegisterUserFormDisplayDriver>(); } } [Feature(UserConstants.Features.UserRegistration)] [RequireFeatures("OrchardCore.Deployment")] - public class RegistrationDeploymentStartup : StartupBase + public sealed class RegistrationDeploymentStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -399,8 +393,8 @@ public override void ConfigureServices(IServiceCollection services) } } - [Feature("OrchardCore.Users.ResetPassword")] - public class ResetPasswordStartup : StartupBase + [Feature(UserConstants.Features.ResetPassword)] + public sealed class ResetPasswordStartup : StartupBase { private const string ForgotPasswordPath = "ForgotPassword"; private const string ForgotPasswordConfirmationPath = "ForgotPasswordConfirmation"; @@ -449,13 +443,16 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped, ResetPasswordSettingsDisplayDriver>(); + services.AddScoped, ResetPasswordFormDisplayDriver>(); + services.AddScoped, ForgotPasswordLoginFormDisplayDriver>(); + services.AddScoped, ForgotPasswordFormDisplayDriver>(); } } - [Feature("OrchardCore.Users.ResetPassword")] + [Feature(UserConstants.Features.ResetPassword)] [RequireFeatures("OrchardCore.Deployment")] - public class ResetPasswordDeploymentStartup : StartupBase + public sealed class ResetPasswordDeploymentStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -464,7 +461,7 @@ public override void ConfigureServices(IServiceCollection services) } [Feature("OrchardCore.Users.CustomUserSettings")] - public class CustomUserSettingsStartup : StartupBase + public sealed class CustomUserSettingsStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { @@ -486,7 +483,7 @@ public override void ConfigureServices(IServiceCollection services) } [RequireFeatures("OrchardCore.Deployment")] - public class UserDeploymentStartup : StartupBase + public sealed class UserDeploymentStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { diff --git a/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ForgotPasswordViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ForgotPasswordViewModel.cs index eca1cc288ee..2fac0702b57 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ForgotPasswordViewModel.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ForgotPasswordViewModel.cs @@ -1,11 +1,15 @@ +using System; using System.ComponentModel.DataAnnotations; namespace OrchardCore.Users.ViewModels { public class ForgotPasswordViewModel { - [Required(ErrorMessage = "Email is required.")] + [Obsolete("Email property is no longer used and will be removed in future releases. Instead use Identifier.")] [Email.EmailAddress(ErrorMessage = "Invalid Email.")] public string Email { get; set; } + + [Required(ErrorMessage = "Username or email address is required.")] + public string Identifier { get; set; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ResetPasswordViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ResetPasswordViewModel.cs index 4a4fc18e612..2e30d1bfb5d 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ResetPasswordViewModel.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ResetPasswordViewModel.cs @@ -1,13 +1,17 @@ +using System; using System.ComponentModel.DataAnnotations; namespace OrchardCore.Users.ViewModels { public class ResetPasswordViewModel { - [Required(ErrorMessage = "Email is required.")] + [Obsolete("Email property is no longer used and will be removed in future releases. Instead use Identifier.")] [Email.EmailAddress(ErrorMessage = "Invalid Email.")] public string Email { get; set; } + [Required(ErrorMessage = "Username or email address is required.")] + public string Identifier { get; set; } + [Required(ErrorMessage = "New password is required.")] [DataType(DataType.Password)] public string NewPassword { get; set; } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/Admin/EditPassword.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/Admin/EditPassword.cshtml index 8f3be420864..fffe05f3d5b 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/Admin/EditPassword.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/Admin/EditPassword.cshtml @@ -4,7 +4,7 @@
- +
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/ForgotPasswordForm.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ForgotPasswordForm.Edit.cshtml new file mode 100644 index 00000000000..feea36c860f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ForgotPasswordForm.Edit.cshtml @@ -0,0 +1,21 @@ + +
+
+

@T["Forgot password?"]

+
+ + + +
+ + @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } + +
+ +
+ +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/ForgotPasswordFormIdentifier.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ForgotPasswordFormIdentifier.cshtml new file mode 100644 index 00000000000..4a1388eba44 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ForgotPasswordFormIdentifier.cshtml @@ -0,0 +1,6 @@ +@model ForgotPasswordViewModel + +
+ + +
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormCredentials.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormCredentials.cshtml similarity index 100% rename from src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormCredentials.Edit.cshtml rename to src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormCredentials.cshtml diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormForgotPassword.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormForgotPassword.cshtml similarity index 100% rename from src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormForgotPassword.Edit.cshtml rename to src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormForgotPassword.cshtml diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormRegisterUser.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormRegisterUser.cshtml similarity index 100% rename from src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormRegisterUser.Edit.cshtml rename to src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormRegisterUser.cshtml diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/RegisterUserForm.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/RegisterUserForm.Edit.cshtml new file mode 100644 index 00000000000..269c642374d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/RegisterUserForm.Edit.cshtml @@ -0,0 +1,20 @@ +
+
+

@T["Register a new account"]

+
+ +
+ +
+ + @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } + +
+ +
+
+
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/RegisterUserFormIdentifier.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/RegisterUserFormIdentifier.cshtml new file mode 100644 index 00000000000..ab7d74be818 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/RegisterUserFormIdentifier.cshtml @@ -0,0 +1,25 @@ +@model RegisterViewModel + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/Registration/Register.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/Registration/Register.cshtml index f44cfc692d5..642dde2c57f 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/Registration/Register.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/Registration/Register.cshtml @@ -1,46 +1,5 @@ -@model OrchardCore.Users.ViewModels.RegisterViewModel - @{ ViewLayout = "Layout__Login"; } -

@T["Register"]

-
-

@T["Create a new account."]

-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
- @await RenderSectionAsync("AfterRegister", required: false) -
-
- -
-
-
+@await DisplayAsync(Model) diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ForgotPassword.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ForgotPassword.cshtml index e43fd8891bb..642dde2c57f 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ForgotPassword.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ForgotPassword.cshtml @@ -1,28 +1,5 @@ -@model OrchardCore.Users.ViewModels.ForgotPasswordViewModel - @{ ViewLayout = "Layout__Login"; } -

@T["Forgot password?"]

-

@T["Please check your email to reset your password."]

-
-
-
-
-
-
- -
- -
-
- @await RenderSectionAsync("AfterForgotPassword", required: false) -
-
- -
-
-
-
-
+@await DisplayAsync(Model) diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ForgotPasswordConfirmation.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ForgotPasswordConfirmation.cshtml index 5baf462afa4..015f9f8acb7 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ForgotPasswordConfirmation.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ForgotPasswordConfirmation.cshtml @@ -2,5 +2,5 @@ ViewLayout = "Layout__Login"; } -

@T["Forgot Password confirmation"]

+

@T["Forgot Password Confirmation"]

@T["Please check your email to reset your password."]

diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ResetPassword.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ResetPassword.cshtml index 0db1cb4464f..642dde2c57f 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ResetPassword.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPassword/ResetPassword.cshtml @@ -1,40 +1,5 @@ -@model OrchardCore.Users.ViewModels.ResetPasswordViewModel - @{ ViewLayout = "Layout__Login"; } -
-

@T["Reset password"]

-
-
-
- -
- - -
-
-
- -
- -
- -
-
-
- -
- - -
-
- @await RenderSectionAsync("AfterResetPassword", required: false) - -
-
- -
-
-
+@await DisplayAsync(Model) diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPasswordForm.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPasswordForm.Edit.cshtml new file mode 100644 index 00000000000..1406f866866 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPasswordForm.Edit.cshtml @@ -0,0 +1,20 @@ +
+
+

@T["Reset password"]

+
+ +
+ +
+ + @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } + +
+ +
+
+
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPasswordFormIdentifier.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPasswordFormIdentifier.cshtml new file mode 100644 index 00000000000..d2fa7d15272 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ResetPasswordFormIdentifier.cshtml @@ -0,0 +1,22 @@ +@model ResetPasswordViewModel + + + +
+ + + +
+ +
+ + +
+ +
+ +
+ + + +
diff --git a/src/OrchardCore/OrchardCore.Users.Abstractions/Services/IUserService.cs b/src/OrchardCore/OrchardCore.Users.Abstractions/Services/IUserService.cs index d32e7dbcf85..8cbf8a7bb20 100644 --- a/src/OrchardCore/OrchardCore.Users.Abstractions/Services/IUserService.cs +++ b/src/OrchardCore/OrchardCore.Users.Abstractions/Services/IUserService.cs @@ -12,11 +12,11 @@ public interface IUserService /// /// Authenticates the user credentials. /// - /// The username or email address. + /// The username or email address. /// The user password. /// The error reported in case failure happened during the authentication process. /// A that represents an authenticated user. - Task AuthenticateAsync(string userName, string password, Action reportError); + Task AuthenticateAsync(string identifier, string password, Action reportError); /// /// Creates a user. @@ -56,33 +56,33 @@ public interface IUserService /// /// Gets the user with a specified username or email address. /// - /// The username. + /// The username or email address. /// The represents the retrieved user. - Task GetUserAsync(string userName); + Task GetUserAsync(string identifier); /// /// Gets the user with a specified ID. /// - /// The user ID. + /// The user ID. /// A represents a retrieved user. - Task GetUserByUniqueIdAsync(string userIdentifier); + Task GetUserByUniqueIdAsync(string userId); /// /// Get a forgotten password for a specified user ID. /// - /// The user ID. - /// A represents a user with forgotton password. - Task GetForgotPasswordUserAsync(string userIdentifier); + /// The user ID. + /// A represents a user with forgotten password. + Task GetForgotPasswordUserAsync(string userId); /// /// Resets the user password. /// - /// The user email address. + /// The username or email address. /// The token used to reset the password. /// The new password. /// The error reported in case failure happened during the reset process. /// Returns true if the password reset, otherwise false. - Task ResetPasswordAsync(string emailAddress, string resetToken, string newPassword, Action reportError); + Task ResetPasswordAsync(string identifier, string resetToken, string newPassword, Action reportError); /// /// Creates a for a given user. diff --git a/src/OrchardCore/OrchardCore.Users.Core/Models/ForgotPasswordForm.cs b/src/OrchardCore/OrchardCore.Users.Core/Models/ForgotPasswordForm.cs new file mode 100644 index 00000000000..cfe629664bb --- /dev/null +++ b/src/OrchardCore/OrchardCore.Users.Core/Models/ForgotPasswordForm.cs @@ -0,0 +1,8 @@ +using OrchardCore.Entities; + +namespace OrchardCore.Users.Models; + +public class ForgotPasswordForm : Entity +{ + public string Identifier { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Users.Core/Models/RegisterUserForm.cs b/src/OrchardCore/OrchardCore.Users.Core/Models/RegisterUserForm.cs new file mode 100644 index 00000000000..6d2d40f64ef --- /dev/null +++ b/src/OrchardCore/OrchardCore.Users.Core/Models/RegisterUserForm.cs @@ -0,0 +1,12 @@ +using OrchardCore.Entities; + +namespace OrchardCore.Users.Models; + +public class RegisterUserForm : Entity +{ + public string UserName { get; set; } + + public string Email { get; set; } + + public string Password { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Users.Core/Models/ResetPasswordForm.cs b/src/OrchardCore/OrchardCore.Users.Core/Models/ResetPasswordForm.cs new file mode 100644 index 00000000000..d5dfc31a784 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Users.Core/Models/ResetPasswordForm.cs @@ -0,0 +1,12 @@ +using OrchardCore.Entities; + +namespace OrchardCore.Users.Models; + +public class ResetPasswordForm : Entity +{ + public string Identifier { get; set; } + + public string NewPassword { get; set; } + + public string ResetToken { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Users.Core/Services/UserService.cs b/src/OrchardCore/OrchardCore.Users.Core/Services/UserService.cs index 99329d57086..f05c7eb8bbc 100644 --- a/src/OrchardCore/OrchardCore.Users.Core/Services/UserService.cs +++ b/src/OrchardCore/OrchardCore.Users.Core/Services/UserService.cs @@ -20,12 +20,13 @@ public class UserService : IUserService { private readonly SignInManager _signInManager; private readonly UserManager _userManager; - private readonly IOptions _identityOptions; + private readonly IdentityOptions _identityOptions; private readonly IEnumerable _passwordRecoveryFormEvents; - protected readonly IStringLocalizer S; private readonly ISiteService _siteService; private readonly ILogger _logger; + protected readonly IStringLocalizer S; + public UserService( SignInManager signInManager, UserManager userManager, @@ -37,14 +38,14 @@ public UserService( { _signInManager = signInManager; _userManager = userManager; - _identityOptions = identityOptions; + _identityOptions = identityOptions.Value; _passwordRecoveryFormEvents = passwordRecoveryFormEvents; S = stringLocalizer; _siteService = siteService; _logger = logger; } - public async Task AuthenticateAsync(string userName, string password, Action reportError) + public async Task AuthenticateAsync(string identifier, string password, Action reportError) { var disableLocalLogin = (await _siteService.GetSiteSettingsAsync()).As().DisableLocalLogin; @@ -54,9 +55,9 @@ public async Task AuthenticateAsync(string userName, string password, Act return null; } - if (string.IsNullOrWhiteSpace(userName)) + if (string.IsNullOrWhiteSpace(identifier)) { - reportError("UserName", S["A user name is required."]); + reportError("Username", S["A user name is required."]); return null; } @@ -66,7 +67,7 @@ public async Task AuthenticateAsync(string userName, string password, Act return null; } - var user = await GetUserAsync(userName); + var user = await GetUserAsync(identifier); if (user == null) { reportError(string.Empty, S["The specified username/password couple is invalid."]); @@ -161,43 +162,46 @@ public Task GetAuthenticatedUserAsync(ClaimsPrincipal principal) return _userManager.GetUserAsync(principal); } - public async Task GetForgotPasswordUserAsync(string userIdentifier) + public async Task GetForgotPasswordUserAsync(string userId) { - if (string.IsNullOrWhiteSpace(userIdentifier)) + if (string.IsNullOrWhiteSpace(userId)) { return await Task.FromResult(null); } - var user = await _userManager.FindByEmailAsync(userIdentifier) as User; + var user = await GetUserAsync(userId); if (user == null) { return await Task.FromResult(null); } - user.ResetToken = await _userManager.GeneratePasswordResetTokenAsync(user); + if (user is User u) + { + u.ResetToken = await _userManager.GeneratePasswordResetTokenAsync(user); + } return user; } - public async Task ResetPasswordAsync(string emailAddress, string resetToken, string newPassword, Action reportError) + public async Task ResetPasswordAsync(string identifier, string resetToken, string newPassword, Action reportError) { var result = true; - if (string.IsNullOrWhiteSpace(emailAddress)) + if (string.IsNullOrWhiteSpace(identifier)) { - reportError("UserName", S["A email address is required."]); + reportError(nameof(ResetPasswordForm.Identifier), S["A username or email address is required."]); result = false; } if (string.IsNullOrWhiteSpace(newPassword)) { - reportError("Password", S["A password is required."]); + reportError(nameof(ResetPasswordForm.NewPassword), S["A password is required."]); result = false; } if (string.IsNullOrWhiteSpace(resetToken)) { - reportError("Token", S["A token is required."]); + reportError(nameof(ResetPasswordForm.ResetToken), S["A token is required."]); result = false; } @@ -206,7 +210,7 @@ public async Task ResetPasswordAsync(string emailAddress, string resetToke return result; } - var user = await _userManager.FindByEmailAsync(emailAddress) as User; + var user = await GetUserAsync(identifier) as User; if (user == null) { @@ -240,8 +244,17 @@ public Task CreatePrincipalAsync(IUser user) return _signInManager.CreateUserPrincipalAsync(user); } - public async Task GetUserAsync(string userName) => - (await _userManager.FindByNameAsync(userName)) ?? await _userManager.FindByEmailAsync(userName); + public async Task GetUserAsync(string identifier) + { + var user = await _userManager.FindByNameAsync(identifier); + + if (user is null && _identityOptions.User.RequireUniqueEmail) + { + user = await _userManager.FindByEmailAsync(identifier); + } + + return user; + } public Task GetUserByUniqueIdAsync(string userIdentifier) => _userManager.FindByIdAsync(userIdentifier); @@ -265,10 +278,10 @@ public void ProcessValidationErrors(IEnumerable errors, User user reportError("Password", S["Passwords must have at least one non letter or digit character."]); break; case "PasswordTooShort": - reportError("Password", S["Passwords must be at least {0} characters.", _identityOptions.Value.Password.RequiredLength]); + reportError("Password", S["Passwords must be at least {0} characters.", _identityOptions.Password.RequiredLength]); break; case "PasswordRequiresUniqueChars": - reportError("Password", S["Passwords must contain at least {0} unique characters.", _identityOptions.Value.Password.RequiredUniqueChars]); + reportError("Password", S["Passwords must contain at least {0} unique characters.", _identityOptions.Password.RequiredUniqueChars]); break; // CurrentPassword. diff --git a/src/OrchardCore/OrchardCore.Users.Core/UserConstants.cs b/src/OrchardCore/OrchardCore.Users.Core/UserConstants.cs index f26bebb564b..a4692395ea0 100644 --- a/src/OrchardCore/OrchardCore.Users.Core/UserConstants.cs +++ b/src/OrchardCore/OrchardCore.Users.Core/UserConstants.cs @@ -19,5 +19,8 @@ public class Features public const string UserEmailConfirmation = "OrchardCore.Users.EmailConfirmation"; public const string UserRegistration = "OrchardCore.Users.Registration"; + + public const string ResetPassword = "OrchardCore.Users.ResetPassword"; + } } diff --git a/src/docs/releases/1.9.0.md b/src/docs/releases/1.9.0.md index cead90f9eb6..a7b8c2f5a38 100644 --- a/src/docs/releases/1.9.0.md +++ b/src/docs/releases/1.9.0.md @@ -85,7 +85,7 @@ If you were using the `OrchardCore_Twitter` configuration key to configure the m ### Users Module -The `Login.cshtml` has undergone a significant revamp. The previous `AfterLogin` zone, which allowed filters for injecting shapes, has been replaced. Now, you can inject shapes using drivers by implementing `IDisplayDriver`. For example, the 'Forgot Password?' link is injected using the following driver: +- The `Login.cshtml` has undergone a significant revamp. The previous `AfterLogin` zone, which allowed filters for injecting shapes, has been replaced. Now, you can inject shapes using drivers by implementing `IDisplayDriver`. For example, the 'Forgot Password?' link is injected using the following driver: ```csharp public class ForgotPasswordLoginFormDisplayDriver : DisplayDriver @@ -111,6 +111,85 @@ public class ForgotPasswordLoginFormDisplayDriver : DisplayDriver } ``` +- The `ForgotPassword.cshtml` has undergone a significant revamp. The previous `AfterForgotPassword` zone, which allowed filters for injecting shapes, has been replaced. Now, you can inject shapes using drivers by implementing `IDisplayDriver`. For example, the ReCaptcha shape is injected using the following driver: + +```csharp +public class ReCaptchaForgotPasswordFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + + public ReCaptchaForgotPasswordFormDisplayDriver(ISiteService siteService) + { + _siteService = siteService; + } + + public override async Task EditAsync(ForgotPasswordForm model, BuildEditorContext context) + { + var _reCaptchaSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!_reCaptchaSettings.IsValid()) + { + return null; + } + + return View("FormReCaptcha_Edit", model).Location("Content:after"); + } +} +``` + +- The `ResetPassword.cshtml` has undergone a significant revamp. The previous `AfterResetPassword` zone, which allowed filters for injecting shapes, has been replaced. Now, you can inject shapes using drivers by implementing `IDisplayDriver`. For example, the ReCaptcha shape is injected using the following driver: + +```csharp +public class ReCaptchaResetPasswordFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + + public ReCaptchaResetPasswordFormDisplayDriver(ISiteService siteService) + { + _siteService = siteService; + } + + public override async Task EditAsync(ResetPasswordForm model, BuildEditorContext context) + { + var _reCaptchaSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!_reCaptchaSettings.IsValid()) + { + return null; + } + + return View("FormReCaptcha_Edit", model).Location("Content:after"); + } +} +``` + +Previously, users were only able to reset their password through email when the "Reset Password" feature was enabled. However, we've enhanced this functionality to offer users the flexibility of resetting their password using either their email or username. Consequently, the `Email` property on both the `ForgotPasswordViewModel` and `ResetPasswordViewModel` have been deprecated and should be replaced with the new Identifier property for password resets. + +- The `Register.cshtml` has undergone a significant revamp. The previous `AfterRegister` zone, which allowed filters for injecting shapes, has been replaced. Now, you can inject shapes using drivers by implementing `IDisplayDriver`. For example, the ReCaptcha shape is injected using the following driver: + +```csharp +public class RegisterUserFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + + public RegisterUserFormDisplayDriver(ISiteService siteService) + { + _siteService = siteService; + } + + public override async Task EditAsync(RegisterUserForm model, BuildEditorContext context) + { + var _reCaptchaSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!_reCaptchaSettings.IsValid()) + { + return null; + } + + return View("FormReCaptcha_Edit", model).Location("Content:after"); + } +} +``` ### Media Indexing Previously, `.pdf` files were automatically indexed in the search providers (Elasticsearch, Lucene or Azure AI Search). Now, if you want to continue to index `.PDF` file you'll need to enable the `OrchardCore.Media.Indexing.Pdf` feature. diff --git a/test/OrchardCore.Tests/Apis/Context/TestRecipeHarvester.cs b/test/OrchardCore.Tests/Apis/Context/TestRecipeHarvester.cs index c8c63faeced..e381777ac41 100644 --- a/test/OrchardCore.Tests/Apis/Context/TestRecipeHarvester.cs +++ b/test/OrchardCore.Tests/Apis/Context/TestRecipeHarvester.cs @@ -15,7 +15,8 @@ public TestRecipeHarvester(IRecipeReader recipeReader) public Task> HarvestRecipesAsync() => HarvestRecipesAsync( [ - "Apis/Lucene/Recipes/luceneQueryTest.json" + "Apis/Lucene/Recipes/luceneQueryTest.json", + "OrchardCore.Users/Recipes/UserSettingsTest.json" ]); private async Task> HarvestRecipesAsync(string[] paths) diff --git a/test/OrchardCore.Tests/OrchardCore.Tests.csproj b/test/OrchardCore.Tests/OrchardCore.Tests.csproj index 946db171578..098f216395e 100644 --- a/test/OrchardCore.Tests/OrchardCore.Tests.csproj +++ b/test/OrchardCore.Tests/OrchardCore.Tests.csproj @@ -20,6 +20,7 @@ + @@ -38,6 +39,7 @@ + diff --git a/test/OrchardCore.Tests/OrchardCore.Users/AntiForgeryHelper.cs b/test/OrchardCore.Tests/OrchardCore.Users/AntiForgeryHelper.cs new file mode 100644 index 00000000000..2a6fa2fb260 --- /dev/null +++ b/test/OrchardCore.Tests/OrchardCore.Users/AntiForgeryHelper.cs @@ -0,0 +1,27 @@ +using System.Text.RegularExpressions; + +namespace OrchardCore.Tests.OrchardCore.Users; + +public partial class AntiForgeryHelper +{ + public static string ExtractAntiForgeryToken(string htmlResponseText) + { + ArgumentException.ThrowIfNullOrEmpty(htmlResponseText); + + var match = RequestVerificationTokenRegex().Match(htmlResponseText); + + return match.Success ? match.Groups[1].Captures[0].Value : null; + } + + public static async Task ExtractAntiForgeryToken(HttpResponseMessage response) + { + ArgumentNullException.ThrowIfNull(response); + + var raw = await response.Content.ReadAsStringAsync(); + + return await Task.FromResult(ExtractAntiForgeryToken(raw)); + } + + [GeneratedRegex(@"\")] + private static partial Regex RequestVerificationTokenRegex(); +} diff --git a/test/OrchardCore.Tests/OrchardCore.Users/CookiesHelper.cs b/test/OrchardCore.Tests/OrchardCore.Users/CookiesHelper.cs new file mode 100644 index 00000000000..8745051f585 --- /dev/null +++ b/test/OrchardCore.Tests/OrchardCore.Users/CookiesHelper.cs @@ -0,0 +1,39 @@ +using Microsoft.Net.Http.Headers; + +namespace OrchardCore.Tests.OrchardCore.Users; + +public class CookiesHelper +{ + public static IDictionary ExtractCookies(HttpResponseMessage response) + { + ArgumentNullException.ThrowIfNull(response); + + var result = new Dictionary(); + + if (response.Headers.TryGetValues("Set-Cookie", out var values)) + { + foreach (var cookie in SetCookieHeaderValue.ParseList(values.ToList())) + { + result.Add(cookie.Name.ToString(), cookie.Value.ToString()); + } + } + + return result; + } + + public static HttpRequestMessage AddCookiesToRequest(HttpRequestMessage request, IDictionary cookies) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(cookies); + + foreach (var key in cookies.Keys) + { + request.Headers.Add("Cookie", new CookieHeaderValue(key, cookies[key]).ToString()); + } + + return request; + } + + public static HttpRequestMessage CopyCookies(HttpRequestMessage source, HttpResponseMessage destination) + => AddCookiesToRequest(source, ExtractCookies(destination)); +} diff --git a/test/OrchardCore.Tests/OrchardCore.Users/PostRequestHelper.cs b/test/OrchardCore.Tests/OrchardCore.Users/PostRequestHelper.cs new file mode 100644 index 00000000000..155c0ca9efd --- /dev/null +++ b/test/OrchardCore.Tests/OrchardCore.Users/PostRequestHelper.cs @@ -0,0 +1,36 @@ +namespace OrchardCore.Tests.OrchardCore.Users; + +public class PostRequestHelper +{ + public static HttpRequestMessage Create(string path, Dictionary data) + { + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(data); + + var message = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = new FormUrlEncodedContent(ToFormPostData(data)) + }; + + return message; + } + + public static HttpRequestMessage CreateMessageWithCookies(string path, Dictionary data, HttpResponseMessage response) + { + var message = Create(path, data); + + return CookiesHelper.CopyCookies(message, response); + } + + private static List> ToFormPostData(Dictionary data) + { + var result = new List>(); + + foreach (var key in data.Keys) + { + result.Add(new KeyValuePair(key, data[key])); + } + + return result; + } +} diff --git a/test/OrchardCore.Tests/OrchardCore.Users/Recipes/UserSettingsTest.json b/test/OrchardCore.Tests/OrchardCore.Users/Recipes/UserSettingsTest.json new file mode 100644 index 00000000000..224bf8a07fc --- /dev/null +++ b/test/OrchardCore.Tests/OrchardCore.Users/Recipes/UserSettingsTest.json @@ -0,0 +1,13 @@ +{ + "name": "UserSettingsTest", + "steps": [ + { + "name": "settings", + "IdentitySettings": { + "UserSettings": { + "RequireUniqueEmail": false + } + } + } + ] +} diff --git a/test/OrchardCore.Tests/OrchardCore.Users/RegistrationControllerTests.cs b/test/OrchardCore.Tests/OrchardCore.Users/RegistrationControllerTests.cs index 045acc28c69..fba421b4481 100644 --- a/test/OrchardCore.Tests/OrchardCore.Users/RegistrationControllerTests.cs +++ b/test/OrchardCore.Tests/OrchardCore.Users/RegistrationControllerTests.cs @@ -1,229 +1,330 @@ -using OrchardCore.DisplayManagement; -using OrchardCore.DisplayManagement.Notify; -using OrchardCore.Email; +using OrchardCore.Entities; +using OrchardCore.Environment.Extensions; +using OrchardCore.Environment.Extensions.Features; +using OrchardCore.Environment.Shell; +using OrchardCore.Recipes.Services; using OrchardCore.Settings; -using OrchardCore.Tests.Utilities; +using OrchardCore.Tests.Apis.Context; using OrchardCore.Users; using OrchardCore.Users.Controllers; -using OrchardCore.Users.Events; using OrchardCore.Users.Models; -using OrchardCore.Users.Services; using OrchardCore.Users.ViewModels; -namespace OrchardCore.Tests.OrchardCore.Users +namespace OrchardCore.Tests.OrchardCore.Users; + +public class RegistrationControllerTests { - public class RegistrationControllerTests + [Fact] + public async Task Register_WhenAllowed_RegisterUser() { - [Fact] - public async Task UsersShouldNotBeAbleToRegisterIfNotAllowed() + // Arrange + var context = await GetSiteContextAsync(new RegistrationSettings() { - // Arrange - var controller = SetupRegistrationController(new RegistrationSettings - { - UsersCanRegister = UserRegistrationType.NoRegistration - }); + UsersCanRegister = UserRegistrationType.AllowRegistration, + }); - // Act & Assert - var result = await controller.Register(); - Assert.IsType(result); + var responseFromGet = await context.Client.GetAsync("Register"); - result = await controller.Register(new RegisterViewModel()); - Assert.IsType(result); - } + Assert.True(responseFromGet.IsSuccessStatusCode); - [Fact] - public async Task UsersShouldBeAbleToRegisterIfAllowed() + // Act + var model = new RegisterViewModel() { - // Arrange - var controller = SetupRegistrationController(); + UserName = "test", + Email = "test@orchardcore.com", + Password = "test@OC!123", + ConfirmPassword = "test@OC!123", + }; - // Act & Assert - var result = await controller.Register(); - Assert.IsType(result); + var response = await context.Client.SendAsync(await CreateRequestMessageAsync(model, responseFromGet)); - result = await controller.Register(new RegisterViewModel { UserName = "Admin", Email = "admin@orchardcore.net" }); - Assert.IsType(result); - } + // Assert + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal($"/{context.TenantName}/", response.Headers.Location.ToString()); - [Fact] - public async Task UsersShouldNotBeAbleToRegisterIfEmailIsDuplicate() + await context.UsingTenantScopeAsync(async scope => { - // Arrange + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var user = await userManager.FindByNameAsync(model.UserName) as User; - var controller = SetupRegistrationController(); + Assert.NotNull(user); + Assert.Equal(model.Email, user.Email); + }); + } - // Act - _ = await controller.Register(new RegisterViewModel { UserName = "SuperAdmin", Email = "admin@orchardcore.net" }); - var result = await controller.Register(new RegisterViewModel { UserName = "Admin", Email = "admin@orchardcore.net" }); + [Fact] + public async Task Register_WhenNotAllowed_ReturnNotFound() + { + // Arrange + var context = await GetSiteContextAsync(new RegistrationSettings() + { + UsersCanRegister = UserRegistrationType.NoRegistration, + }); - // Assert - Assert.IsType(result); - Assert.False(controller.ViewData.ModelState.IsValid); - Assert.True(controller.ViewData.ModelState["Email"].Errors.Count == 1); - Assert.Equal("A user with the same email already exists.", controller.ViewData.ModelState["Email"].Errors[0].ErrorMessage); - } + // Act + var response = await context.Client.GetAsync("Register"); - [Fact] - public async Task UsersCanRequireModeration() + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Register_WhenFeatureIsNotEnable_ReturnNotFound() + { + // Arrange + var context = await GetSiteContextAsync(new RegistrationSettings() { - // Arrange - var controller = SetupRegistrationController(new RegistrationSettings - { - UsersCanRegister = UserRegistrationType.AllowRegistration, - UsersAreModerated = true, - }); + UsersCanRegister = UserRegistrationType.AllowRegistration, + }, enableRegistrationFeature: false); + + // Act + var response = await context.Client.GetAsync("Register"); - // Act - var result = await controller.Register(new RegisterViewModel { UserName = "ModerateMe", Email = "requiresmoderation@orchardcore.net" }); + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Register_WhenRequireUniqueEmailIsTrue_PreventRegisteringMultipleUsersWithTheSameEmails() + { + // Arrange + var context = await GetSiteContextAsync(new RegistrationSettings() + { + UsersCanRegister = UserRegistrationType.AllowRegistration, + }); + + var responseFromGet = await context.Client.GetAsync("Register"); + + Assert.True(responseFromGet.IsSuccessStatusCode); - // Assert - Assert.IsType(result); - Assert.Equal("RegistrationPending", ((RedirectToActionResult)result).ActionName); - } + var emailAddress = "test@orchardcore.com"; - [Fact] - public async Task UsersCanRequireEmailConfirmation() + var requestForPost = await CreateRequestMessageAsync(new RegisterViewModel() { - // Arrange - var controller = SetupRegistrationController(new RegistrationSettings - { - UsersCanRegister = UserRegistrationType.AllowRegistration, - UsersMustValidateEmail = true - }); + UserName = "test1", + Email = emailAddress, + Password = "test1@OC!123", + ConfirmPassword = "test1@OC!123", + }, responseFromGet); + + // Act + var responseFromPost1 = await context.Client.SendAsync(requestForPost); + + Assert.Equal(HttpStatusCode.Redirect, responseFromPost1.StatusCode); + + var responseFromGet2 = await context.Client.GetAsync("Register"); + + Assert.True(responseFromGet2.IsSuccessStatusCode); + + var requestForPost2 = await CreateRequestMessageAsync(new RegisterViewModel() + { + UserName = "test2", + Email = emailAddress, + Password = "test2@OC!123", + ConfirmPassword = "test2@OC!123", + }, responseFromGet); + + var responseFromPost2 = await context.Client.SendAsync(requestForPost2); + + // Assert + Assert.True(responseFromPost2.IsSuccessStatusCode); + Assert.Contains("A user with the same email address already exists.", await responseFromPost2.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Register_WhenRequireUniqueEmailIsFalse_AllowRegisteringMultipleUsersWithTheSameEmails() + { + // Arrange + var context = await GetSiteContextAsync(new RegistrationSettings() + { + UsersCanRegister = UserRegistrationType.AllowRegistration, + }, enableRegistrationFeature: true, requireUniqueEmail: false); + + // Register First User + var responseFromGet = await context.Client.GetAsync("Register"); + + Assert.True(responseFromGet.IsSuccessStatusCode); + var emailAddress = "test@orchardcore.com"; + + var requestForPost = await CreateRequestMessageAsync(new RegisterViewModel() + { + UserName = "test1", + Email = emailAddress, + Password = "test1@OC!123", + ConfirmPassword = "test1@OC!123", + }, responseFromGet); + + var responseFromPost = await context.Client.SendAsync(requestForPost); + + Assert.Equal(HttpStatusCode.Redirect, responseFromPost.StatusCode); + + // Register Second User + var responseFromGet2 = await context.Client.GetAsync("Register"); + + Assert.True(responseFromGet2.IsSuccessStatusCode); + + var requestForPost2 = await CreateRequestMessageAsync(new RegisterViewModel() + { + UserName = "test2", + Email = emailAddress, + Password = "test2@OC!123", + ConfirmPassword = "test2@OC!123", + }, responseFromGet); + + var responseFromPost2 = await context.Client.SendAsync(requestForPost2); + + Assert.Equal(HttpStatusCode.Redirect, responseFromPost2.StatusCode); + + var body = await responseFromPost2.Content.ReadAsStringAsync(); - // Act & Assert - var result = await controller.Register(new RegisterViewModel { UserName = "ConfirmMe", Email = "requiresemailconfirmation@orchardcore.net" }); + Assert.DoesNotContain("A user with the same email address already exists.", body); + } + + [Fact] + public async Task Register_WhenModeration_RedirectToRegistrationPending() + { + // Arrange + var context = await GetSiteContextAsync(new RegistrationSettings() + { + UsersCanRegister = UserRegistrationType.AllowRegistration, + UsersAreModerated = true, + }); + + var responseFromGet = await context.Client.GetAsync("Register"); + + Assert.True(responseFromGet.IsSuccessStatusCode); + + // Act + var model = new RegisterViewModel() + { + UserName = "ModerateMe", + Email = "ModerateMe@orchardcore.com", + Password = "ModerateMe@OC!123", + ConfirmPassword = "ModerateMe@OC!123", + }; + + var responseFromPost = await context.Client.SendAsync(await CreateRequestMessageAsync(model, responseFromGet)); + + // Assert + Assert.Equal(HttpStatusCode.Redirect, responseFromPost.StatusCode); + Assert.Equal($"/{context.TenantName}/{nameof(RegistrationController.RegistrationPending)}", responseFromPost.Headers.Location.ToString()); + + await context.UsingTenantScopeAsync(async scope => + { + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var user = await userManager.FindByNameAsync(model.UserName) as User; - // Assert - Assert.IsType(result); - Assert.Equal("ConfirmEmailSent", ((RedirectToActionResult)result).ActionName); - } + Assert.NotNull(user); + Assert.Equal(model.Email, user.Email); + Assert.False(user.IsEnabled); + }); + } - private static Mock> MockSignInManager(UserManager userManager = null) where TUser : class, IUser + [Fact] + public async Task Register_WhenRequireEmailConfirmation_RedirectToConfirmEmailSent() + { + // Arrange + var context = await GetSiteContextAsync(new RegistrationSettings() { - var context = new Mock(); - var manager = userManager ?? UsersMockHelper.MockUserManager().Object; + UsersCanRegister = UserRegistrationType.AllowRegistration, + UsersMustValidateEmail = true, + }); + + var responseFromGet = await context.Client.GetAsync("Register"); - var signInManager = new Mock>( - manager, - new HttpContextAccessor { HttpContext = context.Object }, - Mock.Of>(), - null, - null, - null, - null) - { CallBase = true }; + Assert.True(responseFromGet.IsSuccessStatusCode); - signInManager.Setup(x => x.SignInAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); + // Act + var model = new RegisterViewModel() + { + UserName = "ConfirmMe", + Email = "ConfirmMe@orchardcore.com", + Password = "ConfirmMe@OC!123", + ConfirmPassword = "ConfirmMe@OC!123", + }; + + var requestForPost = await CreateRequestMessageAsync(model, responseFromGet); + + var responseFromPost = await context.Client.SendAsync(requestForPost); + + // Assert + Assert.Equal(HttpStatusCode.Redirect, responseFromPost.StatusCode); + Assert.Equal($"/{context.TenantName}/{nameof(RegistrationController.ConfirmEmailSent)}", responseFromPost.Headers.Location.ToString()); + + await context.UsingTenantScopeAsync(async scope => + { + var userManager = scope.ServiceProvider.GetRequiredService>(); - return signInManager; - } + var user = await userManager.FindByNameAsync(model.UserName) as User; - private static RegistrationController SetupRegistrationController() - => SetupRegistrationController(new RegistrationSettings + Assert.NotNull(user); + Assert.Equal(model.Email, user.Email); + Assert.False(user.EmailConfirmed); + }); + } + + private static async Task CreateRequestMessageAsync(RegisterViewModel model, HttpResponseMessage response) + { + var data = new Dictionary + { + {"__RequestVerificationToken", await AntiForgeryHelper.ExtractAntiForgeryToken(response) }, + {$"{nameof(RegisterUserForm)}.{nameof(model.UserName)}", model.UserName}, + {$"{nameof(RegisterUserForm)}.{nameof(model.Email)}", model.Email}, + {$"{nameof(RegisterUserForm)}.{nameof(model.Password)}", model.Password}, + {$"{nameof(RegisterUserForm)}.{nameof(model.ConfirmPassword)}", model.ConfirmPassword}, + }; + + return PostRequestHelper.CreateMessageWithCookies("Register", data, response); + } + + private static async Task GetSiteContextAsync(RegistrationSettings settings, bool enableRegistrationFeature = true, bool requireUniqueEmail = true) + { + var context = new SiteContext(); + + await context.InitializeAsync(); + + await context.UsingTenantScopeAsync(async scope => + { + if (!requireUniqueEmail) { - UsersCanRegister = UserRegistrationType.AllowRegistration - }); - - private static RegistrationController SetupRegistrationController(RegistrationSettings registrationSettings) - { - var users = new List(); - var mockUserManager = UsersMockHelper.MockUserManager(); - mockUserManager.Setup(um => um.FindByEmailAsync(It.IsAny())) - .Returns(e => - { - var user = users.SingleOrDefault(u => (u as User).Email == e); - return Task.FromResult(user); - }); - - var mockSite = SiteMockHelper.GetSite(registrationSettings); - - var mockSiteService = Mock.Of(ss => ss.GetSiteSettingsAsync() == Task.FromResult(mockSite.Object)); - var mockSmtpService = Mock.Of(x => x.SendAsync(It.IsAny(), It.IsAny()) == Task.FromResult(EmailResult.SuccessResult)); - var mockStringLocalizer = new Mock>(); - mockStringLocalizer.Setup(l => l[It.IsAny()]) - .Returns(s => new LocalizedString(s, s)); - - var userService = new Mock(); - userService.Setup(u => u.CreateUserAsync(It.IsAny(), It.IsAny(), It.IsAny>())) - .Callback>((u, p, e) => users.Add(u)) - .ReturnsAsync, IUserService, IUser>((u, p, e) => u); - - var urlHelperMock = new Mock(); - urlHelperMock.Setup(urlHelper => urlHelper.Action(It.IsAny())); - - var mockUrlHelperFactory = new Mock(); - mockUrlHelperFactory.Setup(f => f.GetUrlHelper(It.IsAny())) - .Returns(urlHelperMock.Object); - - var mockDisplayHelper = new Mock(); - mockDisplayHelper.Setup(x => x.ShapeExecuteAsync(It.IsAny())) - .ReturnsAsync(HtmlString.Empty); - - var controller = new RegistrationController( - mockUserManager.Object, - Mock.Of(), - mockSiteService, - Mock.Of(), - Mock.Of>(), - Mock.Of>(), - mockStringLocalizer.Object) + var recipeExecutor = scope.ServiceProvider.GetRequiredService(); + var recipeHarvesters = scope.ServiceProvider.GetRequiredService>(); + var recipeCollections = await Task.WhenAll( + recipeHarvesters.Select(recipe => recipe.HarvestRecipesAsync())); + + var recipe = recipeCollections.SelectMany(recipeCollection => recipeCollection) + .FirstOrDefault(recipe => recipe.Name == "UserSettingsTest"); + + var executionId = Guid.NewGuid().ToString("n"); + + await recipeExecutor.ExecuteAsync( + executionId, + recipe, + new Dictionary(), + CancellationToken.None); + } + + if (enableRegistrationFeature) { - Url = urlHelperMock.Object - }; - - var mockServiceProvider = new Mock(); - - mockServiceProvider - .Setup(x => x.GetService(typeof(IEmailService))) - .Returns(mockSmtpService); - mockServiceProvider - .Setup(x => x.GetService(typeof(UserManager))) - .Returns(mockUserManager.Object); - mockServiceProvider - .Setup(x => x.GetService(typeof(ISiteService))) - .Returns(mockSiteService); - mockServiceProvider - .Setup(x => x.GetService(typeof(IEnumerable))) - .Returns(Array.Empty()); - mockServiceProvider - .Setup(x => x.GetService(typeof(IUserService))) - .Returns(userService.Object); - mockServiceProvider - .Setup(x => x.GetService(typeof(SignInManager))) - .Returns(MockSignInManager(mockUserManager.Object).Object); - mockServiceProvider - .Setup(x => x.GetService(typeof(ITempDataDictionaryFactory))) - .Returns(Mock.Of()); - mockServiceProvider - .Setup(x => x.GetService(typeof(IObjectModelValidator))) - .Returns(Mock.Of()); - mockServiceProvider - .Setup(x => x.GetService(typeof(IDisplayHelper))) - .Returns(mockDisplayHelper.Object); - mockServiceProvider - .Setup(x => x.GetService(typeof(IUrlHelperFactory))) - .Returns(mockUrlHelperFactory.Object); - mockServiceProvider - .Setup(x => x.GetService(typeof(HtmlEncoder))) - .Returns(HtmlEncoder.Default); - - // var mockRequest = new Mock(); - // mockRequest.Setup(x => x.Scheme) - // .Returns("http"); - - var mockHttpContext = new Mock(); - mockHttpContext - .Setup(x => x.RequestServices) - .Returns(mockServiceProvider.Object); - mockHttpContext - .Setup(x => x.Request) - .Returns(Mock.Of(x => x.Scheme == "http")); - - controller.ControllerContext.HttpContext = mockHttpContext.Object; - - return controller; - } + var shellFeatureManager = scope.ServiceProvider.GetRequiredService(); + var extensionManager = scope.ServiceProvider.GetRequiredService(); + + var extensionInfo = extensionManager.GetExtension(UserConstants.Features.UserRegistration); + + await shellFeatureManager.EnableFeaturesAsync([new FeatureInfo(UserConstants.Features.UserRegistration, extensionInfo)], true); + } + + var siteService = scope.ServiceProvider.GetRequiredService(); + + var site = await siteService.LoadSiteSettingsAsync(); + + site.Put(settings); + + await siteService.UpdateSiteSettingsAsync(site); + }); + + return context; } }