diff --git a/src/OrchardCore.Modules/OrchardCore.Facebook/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Facebook/Manifest.cs index 2374c361c7a..659243405a1 100644 --- a/src/OrchardCore.Modules/OrchardCore.Facebook/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.Facebook/Manifest.cs @@ -24,7 +24,8 @@ Description = "Authenticates users from Meta.", Dependencies = [ - FacebookConstants.Features.Core + FacebookConstants.Features.Core, + "OrchardCore.Users.ExternalAuthentication", ] )] diff --git a/src/OrchardCore.Modules/OrchardCore.GitHub/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.GitHub/Manifest.cs index 50934ea7ff0..3b719d76abf 100644 --- a/src/OrchardCore.Modules/OrchardCore.GitHub/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.GitHub/Manifest.cs @@ -13,5 +13,9 @@ Id = GitHubConstants.Features.GitHubAuthentication, Name = "GitHub Authentication", Category = "GitHub", - Description = "Authenticates users with their GitHub Account." + Description = "Authenticates users with their GitHub Account.", + Dependencies = + [ + "OrchardCore.Users.ExternalAuthentication", + ] )] diff --git a/src/OrchardCore.Modules/OrchardCore.Google/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Google/Manifest.cs index 5ca5c03429d..731c3576ec8 100644 --- a/src/OrchardCore.Modules/OrchardCore.Google/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.Google/Manifest.cs @@ -13,7 +13,11 @@ Id = GoogleConstants.Features.GoogleAuthentication, Name = "Google Authentication", Category = "Google", - Description = "Authenticates users with their Google Account." + Description = "Authenticates users with their Google Account.", + Dependencies = + [ + "OrchardCore.Users.ExternalAuthentication", + ] )] [assembly: Feature( diff --git a/src/OrchardCore.Modules/OrchardCore.Microsoft.Authentication/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Microsoft.Authentication/Manifest.cs index 0567a313124..cb8444401fd 100644 --- a/src/OrchardCore.Modules/OrchardCore.Microsoft.Authentication/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.Microsoft.Authentication/Manifest.cs @@ -13,12 +13,20 @@ Id = MicrosoftAuthenticationConstants.Features.MicrosoftAccount, Name = "Microsoft Account Authentication", Category = "Microsoft Authentication", - Description = "Authenticates users with their Microsoft Account." + Description = "Authenticates users with their Microsoft Account.", + Dependencies = + [ + "OrchardCore.Users.ExternalAuthentication", + ] )] [assembly: Feature( Id = MicrosoftAuthenticationConstants.Features.AAD, Name = "Microsoft Entra ID (Azure Active Directory) Authentication", Category = "Microsoft Authentication", - Description = "Authenticates users with their Microsoft Entra ID Account." + Description = "Authenticates users with their Microsoft Entra ID Account.", + Dependencies = + [ + "OrchardCore.Users.ExternalAuthentication", + ] )] diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Manifest.cs index d62397ecb78..9984c59fa46 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Manifest.cs @@ -24,6 +24,7 @@ Dependencies = [ OpenIdConstants.Features.Core, + "OrchardCore.Users.ExternalAuthentication", ] )] diff --git a/src/OrchardCore.Modules/OrchardCore.Twitter/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Twitter/Manifest.cs index 338b41b6184..5f51c5f9707 100644 --- a/src/OrchardCore.Modules/OrchardCore.Twitter/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.Twitter/Manifest.cs @@ -21,5 +21,9 @@ Name = "Sign in with X (Twitter)", Category = "X (Twitter)", Description = "Authenticates users with their X (Twitter) Account.", - Dependencies = [TwitterConstants.Features.Twitter] + Dependencies = + [ + TwitterConstants.Features.Twitter, + "OrchardCore.Users.ExternalAuthentication", + ] )] diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountBaseController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountBaseController.cs index a6dffc4612f..333ca658405 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountBaseController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountBaseController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; using OrchardCore.Users.Models; using OrchardCore.Workflows.Services; @@ -36,4 +37,24 @@ protected IActionResult RedirectToLocal(string returnUrl) return Redirect("~/"); } + + protected void CopyTempDataErrorsToModelState() + { + foreach (var errorMessage in TempData.Where(x => x.Key.StartsWith("error")).Select(x => x.Value.ToString())) + { + ModelState.AddModelError(string.Empty, errorMessage); + } + } + + protected bool AddUserEnabledError(IUser user, IStringLocalizer S) + { + if (user is not User localUser || !localUser.IsEnabled) + { + ModelState.AddModelError(string.Empty, S["The specified user is not allowed to sign in."]); + + return true; + } + + return false; + } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs index d37e09f1cc7..4eaaac1c0d0 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs @@ -1,6 +1,3 @@ -using System.Security.Claims; -using System.Text.Json.Nodes; -using System.Text.Json.Settings; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; @@ -11,11 +8,9 @@ using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using OrchardCore.ContentManagement; using OrchardCore.DisplayManagement; using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.DisplayManagement.Notify; -using OrchardCore.Environment.Shell; using OrchardCore.Modules; using OrchardCore.Mvc.Core.Utilities; using OrchardCore.Settings; @@ -25,14 +20,14 @@ using OrchardCore.Users.Services; using OrchardCore.Users.ViewModels; using YesSql.Services; -using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; namespace OrchardCore.Users.Controllers; [Authorize] public sealed class AccountController : AccountBaseController { - public const string DefaultExternalLoginProtector = "DefaultExternalLogin"; + [Obsolete("This property will be removed in v3. Instead use ExternalAuthenticationController.DefaultExternalLoginProtector")] + public const string DefaultExternalLoginProtector = ExternalAuthenticationsController.DefaultExternalLoginProtector; private readonly IUserService _userService; private readonly SignInManager _signInManager; @@ -40,21 +35,14 @@ public sealed class AccountController : AccountBaseController private readonly ILogger _logger; private readonly ISiteService _siteService; private readonly IEnumerable _accountEvents; + private readonly ExternalLoginOptions _externalLoginOptions; + private readonly RegistrationOptions _registrationOptions; private readonly IDataProtectionProvider _dataProtectionProvider; - private readonly IShellFeaturesManager _shellFeaturesManager; private readonly IDisplayManager _loginFormDisplayManager; private readonly IUpdateModelAccessor _updateModelAccessor; private readonly INotifier _notifier; private readonly IClock _clock; private readonly IDistributedCache _distributedCache; - private readonly IEnumerable _externalLoginHandlers; - private readonly IdentityOptions _identityOptions; - - private static readonly JsonMergeSettings _jsonMergeSettings = new() - { - MergeArrayHandling = MergeArrayHandling.Replace, - MergeNullValueHandling = MergeNullValueHandling.Merge - }; internal readonly IHtmlLocalizer H; internal readonly IStringLocalizer S; @@ -68,15 +56,14 @@ public AccountController( IHtmlLocalizer htmlLocalizer, IStringLocalizer stringLocalizer, IEnumerable accountEvents, + IOptions registrationOptions, + IOptions externalLoginOptions, INotifier notifier, IClock clock, IDistributedCache distributedCache, IDataProtectionProvider dataProtectionProvider, - IShellFeaturesManager shellFeaturesManager, IDisplayManager loginFormDisplayManager, - IUpdateModelAccessor updateModelAccessor, - IEnumerable externalLoginHandlers, - IOptions identityOptions) + IUpdateModelAccessor updateModelAccessor) { _signInManager = signInManager; _userManager = userManager; @@ -84,15 +71,14 @@ public AccountController( _logger = logger; _siteService = siteService; _accountEvents = accountEvents; + _externalLoginOptions = externalLoginOptions.Value; + _registrationOptions = registrationOptions.Value; _notifier = notifier; _clock = clock; _distributedCache = distributedCache; _dataProtectionProvider = dataProtectionProvider; - _shellFeaturesManager = shellFeaturesManager; _loginFormDisplayManager = loginFormDisplayManager; _updateModelAccessor = updateModelAccessor; - _externalLoginHandlers = externalLoginHandlers; - _identityOptions = identityOptions.Value; H = htmlLocalizer; S = stringLocalizer; @@ -110,21 +96,23 @@ public async Task Login(string returnUrl = null) // Clear the existing external cookie to ensure a clean login process. await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); - var loginSettings = await _siteService.GetSettingsAsync(); - if (loginSettings.UseExternalProviderIfOnlyOneDefined) + if (_externalLoginOptions.UseExternalProviderIfOnlyOneDefined) { var schemes = await _signInManager.GetExternalAuthenticationSchemesAsync(); if (schemes.Count() == 1) { - var dataProtector = _dataProtectionProvider.CreateProtector(DefaultExternalLoginProtector) - .ToTimeLimitedDataProtector(); + var dataProtector = _dataProtectionProvider.CreateProtector(ExternalAuthenticationsController.DefaultExternalLoginProtector) + .ToTimeLimitedDataProtector(); var token = Guid.NewGuid(); var expiration = new TimeSpan(0, 0, 5); var protectedToken = dataProtector.Protect(token.ToString(), _clock.UtcNow.Add(expiration)); - await _distributedCache.SetAsync(token.ToString(), token.ToByteArray(), new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = expiration }); + await _distributedCache.SetAsync(token.ToString(), token.ToByteArray(), new DistributedCacheEntryOptions() + { + AbsoluteExpirationRelativeToNow = expiration, + }); - return RedirectToAction(nameof(DefaultExternalLogin), new { protectedToken, returnUrl }); + return RedirectToAction(nameof(ExternalAuthenticationsController.DefaultExternalLogin), typeof(ExternalAuthenticationsController).ControllerName(), new { protectedToken, returnUrl }); } } @@ -137,41 +125,6 @@ public async Task Login(string returnUrl = null) return View(formShape); } - [HttpGet] - [AllowAnonymous] - public async Task DefaultExternalLogin(string protectedToken, string returnUrl = null) - { - var loginSettings = await _siteService.GetSettingsAsync(); - if (loginSettings.UseExternalProviderIfOnlyOneDefined) - { - var schemes = await _signInManager.GetExternalAuthenticationSchemesAsync(); - if (schemes.Count() == 1) - { - var dataProtector = _dataProtectionProvider.CreateProtector(DefaultExternalLoginProtector) - .ToTimeLimitedDataProtector(); - - try - { - if (Guid.TryParse(dataProtector.Unprotect(protectedToken), out var token)) - { - var tokenBytes = await _distributedCache.GetAsync(token.ToString()); - var cacheToken = new Guid(tokenBytes); - if (token.Equals(cacheToken)) - { - return ExternalLogin(schemes.First().Name, returnUrl); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred while validating {defaultExternalLogin} token", DefaultExternalLoginProtector); - } - } - } - - return RedirectToAction(nameof(Login)); - } - [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] @@ -184,72 +137,74 @@ public async Task LoginPOST(string returnUrl = null) var formShape = await _loginFormDisplayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, false, string.Empty, string.Empty); - var disableLocalLogin = (await _siteService.GetSettingsAsync()).DisableLocalLogin; + var loginSettings = await _siteService.GetSettingsAsync(); - if (disableLocalLogin) + if (loginSettings.DisableLocalLogin) { ModelState.AddModelError(string.Empty, S["Local login is disabled."]); + + return View(formShape); } - else + + await _accountEvents.InvokeAsync((e, model, modelState) => e.LoggingInAsync(model.UserName, (key, message) => modelState.AddModelError(key, message)), model, ModelState, _logger); + + IUser user = null; + + if (ModelState.IsValid) { - await _accountEvents.InvokeAsync((e, model, modelState) => e.LoggingInAsync(model.UserName, (key, message) => modelState.AddModelError(key, message)), model, ModelState, _logger); + user = await _userService.GetUserAsync(model.UserName); - IUser user = null; - if (ModelState.IsValid) + if (user != null) { - user = await _userService.GetUserAsync(model.UserName); - if (user != null) + var result = await _signInManager.CheckPasswordSignInAsync(user, model.Password, lockoutOnFailure: true); + if (result.Succeeded) { - var result = await _signInManager.CheckPasswordSignInAsync(user, model.Password, lockoutOnFailure: true); - if (result.Succeeded) + if (!await AddConfirmEmailErrorAsync(user) && !AddUserEnabledError(user, S)) { - if (!await AddConfirmEmailErrorAsync(user) && !AddUserEnabledError(user)) - { - result = await _signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, lockoutOnFailure: true); + result = await _signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, lockoutOnFailure: true); - if (result.Succeeded) - { - _logger.LogInformation(1, "User logged in."); - await _accountEvents.InvokeAsync((e, user) => e.LoggedInAsync(user), user, _logger); + if (result.Succeeded) + { + _logger.LogInformation(1, "User logged in."); + await _accountEvents.InvokeAsync((e, user) => e.LoggedInAsync(user), user, _logger); - return await LoggedInActionResultAsync(user, returnUrl); - } + return await LoggedInActionResultAsync(user, returnUrl); } } + } - if (result.RequiresTwoFactor) - { - return RedirectToAction(nameof(TwoFactorAuthenticationController.LoginWithTwoFactorAuthentication), - typeof(TwoFactorAuthenticationController).ControllerName(), - new - { - returnUrl, - model.RememberMe - }); - } + if (result.RequiresTwoFactor) + { + return RedirectToAction(nameof(TwoFactorAuthenticationController.LoginWithTwoFactorAuthentication), + typeof(TwoFactorAuthenticationController).ControllerName(), + new + { + returnUrl, + model.RememberMe + }); + } - if (result.IsLockedOut) - { - ModelState.AddModelError(string.Empty, S["The account is locked out"]); - await _accountEvents.InvokeAsync((e, user) => e.IsLockedOutAsync(user), user, _logger); + if (result.IsLockedOut) + { + ModelState.AddModelError(string.Empty, S["The account is locked out"]); + await _accountEvents.InvokeAsync((e, user) => e.IsLockedOutAsync(user), user, _logger); - return View(); - } + return View(); } - - ModelState.AddModelError(string.Empty, S["Invalid login attempt."]); } - if (user == null) - { - // Login failed unknown user. - await _accountEvents.InvokeAsync((e, model) => e.LoggingInFailedAsync(model.UserName), model, _logger); - } - else - { - // Login failed with a known user. - await _accountEvents.InvokeAsync((e, user) => e.LoggingInFailedAsync(user), user, _logger); - } + ModelState.AddModelError(string.Empty, S["Invalid login attempt."]); + } + + if (user == null) + { + // Login failed unknown user. + await _accountEvents.InvokeAsync((e, model) => e.LoggingInFailedAsync(model.UserName), model, _logger); + } + else + { + // Login failed with a known user. + await _accountEvents.InvokeAsync((e, user) => e.LoggingInFailedAsync(user), user, _logger); } // If we got this far, something failed, redisplay form. @@ -281,7 +236,7 @@ public async Task ChangePassword(ChangePasswordViewModel model, s if (TryValidateModel(model) && ModelState.IsValid) { var user = await _userService.GetAuthenticatedUserAsync(User); - if (await _userService.ChangePasswordAsync(user, model.CurrentPassword, model.Password, (key, message) => ModelState.AddModelError(key, message))) + if (await _userService.ChangePasswordAsync(user, model.CurrentPassword, model.Password, ModelState.AddModelError)) { if (Url.IsLocalUrl(returnUrl)) { @@ -301,668 +256,23 @@ public async Task ChangePassword(ChangePasswordViewModel model, s public IActionResult ChangePasswordConfirmation() => View(); - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public IActionResult ExternalLogin(string provider, string returnUrl = null) - { - // Request a redirect to the external login provider. - var redirectUrl = Url.Action(nameof(ExternalLoginCallback), new { returnUrl }); - var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); - - return Challenge(properties, provider); - } - - private async Task ExternalLoginSignInAsync(IUser user, ExternalLoginInfo info) - { - var externalClaims = info.Principal.GetSerializableClaims(); - var userRoles = await _userManager.GetRolesAsync(user); - var userInfo = user as User; - - var context = new UpdateUserContext(user, info.LoginProvider, externalClaims, userInfo.Properties) - { - UserClaims = userInfo.UserClaims, - UserRoles = userRoles, - }; - foreach (var item in _externalLoginHandlers) - { - try - { - await item.UpdateUserAsync(context); - } - catch (Exception ex) - { - _logger.LogError(ex, "{ExternalLoginHandler}.UpdateUserAsync threw an exception", item.GetType()); - } - } - - if (await UpdateUserPropertiesAsync(_userManager, userInfo, context)) - { - await _userManager.UpdateAsync(user); - } - - var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true); - - if (result.Succeeded) - { - await _accountEvents.InvokeAsync((e, user) => e.LoggedInAsync(user), user, _logger); - - var identityResult = await _signInManager.UpdateExternalAuthenticationTokensAsync(info); - if (!identityResult.Succeeded) - { - _logger.LogError("Error updating the external authentication tokens."); - } - } - else - { - await _accountEvents.InvokeAsync((e, user) => e.LoggingInFailedAsync(user), user, _logger); - } - - return result; - } - - [HttpGet] - [AllowAnonymous] - public async Task ExternalLoginCallback(string returnUrl = null, string remoteError = null) - { - if (remoteError != null) - { - _logger.LogError("Error from external provider: {Error}", remoteError); - ModelState.AddModelError(string.Empty, S["An error occurred in external provider."]); - - return RedirectToLogin(returnUrl); - } - - var info = await _signInManager.GetExternalLoginInfoAsync(); - if (info == null) - { - _logger.LogError("Could not get external login info."); - ModelState.AddModelError(string.Empty, S["An error occurred in external provider."]); - - return RedirectToLogin(returnUrl); - } - - var registrationSettings = await _siteService.GetSettingsAsync(); - var iUser = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey); - - CopyTempDataErrorsToModelState(); - - if (iUser != null) - { - if (!await AddConfirmEmailErrorAsync(iUser) && !AddUserEnabledError(iUser)) - { - await _accountEvents.InvokeAsync((e, user, modelState) => e.LoggingInAsync(user.UserName, (key, message) => modelState.AddModelError(key, message)), iUser, ModelState, _logger); - - var signInResult = await ExternalLoginSignInAsync(iUser, info); - if (signInResult.Succeeded) - { - return await LoggedInActionResultAsync(iUser, returnUrl, info); - } - else - { - ModelState.AddModelError(string.Empty, S["Invalid login attempt."]); - } - } - } - else - { - var email = info.GetEmail(); - - if (_identityOptions.User.RequireUniqueEmail && !string.IsNullOrWhiteSpace(email)) - { - iUser = await _userManager.FindByEmailAsync(email); - } - - ViewData["ReturnUrl"] = returnUrl; - ViewData["LoginProvider"] = info.LoginProvider; - - if (iUser != null) - { - if (iUser is User userToLink && registrationSettings.UsersMustValidateEmail && !userToLink.EmailConfirmed) - { - return RedirectToAction(nameof(EmailConfirmationController.ConfirmEmailSent), - new - { - Area = UserConstants.Features.Users, - Controller = typeof(EmailConfirmationController).ControllerName(), - ReturnUrl = returnUrl, - }); - } - - // Link external login to an existing user - ViewData["UserName"] = iUser.UserName; - - return View(nameof(LinkExternalLogin)); - } - - // No user could be matched, check if a new user can register. - if (registrationSettings.UsersCanRegister == UserRegistrationType.NoRegistration) - { - var message = S["Site does not allow user registration."]; - _logger.LogWarning("Site does not allow user registration."); - ModelState.AddModelError(string.Empty, message); - } - else - { - var externalLoginViewModel = new RegisterExternalLoginViewModel - { - NoPassword = registrationSettings.NoPasswordForExternalUsers, - NoEmail = registrationSettings.NoEmailForExternalUsers, - NoUsername = registrationSettings.NoUsernameForExternalUsers, - - // If registrationSettings.NoUsernameForExternalUsers is true, this username will not be used - UserName = await GenerateUsernameAsync(info), - Email = info.GetEmail(), - }; - - // The user doesn't exist and no information required, we can create the account locally - // instead of redirecting to the ExternalLogin. - var noInformationRequired = externalLoginViewModel.NoPassword - && externalLoginViewModel.NoEmail - && externalLoginViewModel.NoUsername; - - if (noInformationRequired) - { - iUser = await this.RegisterUser(new RegisterUserForm() - { - UserName = externalLoginViewModel.UserName, - Email = externalLoginViewModel.Email, - Password = null, - }, S["Confirm your account"], _logger); - - // If the registration was successful we can link the external provider and redirect the user. - if (iUser != null) - { - var identityResult = await _signInManager.UserManager.AddLoginAsync(iUser, new UserLoginInfo(info.LoginProvider, info.ProviderKey, info.ProviderDisplayName)); - if (identityResult.Succeeded) - { - _logger.LogInformation(3, "User account linked to {LoginProvider} provider.", info.LoginProvider); - - // The login info must be linked before we consider a redirect, or the login info is lost. - if (iUser is User user) - { - if (registrationSettings.UsersMustValidateEmail && !user.EmailConfirmed) - { - return RedirectToAction(nameof(EmailConfirmationController.ConfirmEmailSent), - new - { - Area = UserConstants.Features.Users, - Controller = typeof(EmailConfirmationController).ControllerName(), - ReturnUrl = returnUrl, - }); - } - - if (registrationSettings.UsersAreModerated && !user.IsEnabled) - { - return RedirectToAction(nameof(RegistrationController.RegistrationPending), - new - { - Area = UserConstants.Features.Users, - Controller = typeof(RegistrationController).ControllerName(), - ReturnUrl = returnUrl, - }); - } - } - - // We have created/linked to the local user, so we must verify the login. - // If it does not succeed, the user is not allowed to login - var signInResult = await ExternalLoginSignInAsync(iUser, info); - if (signInResult.Succeeded) - { - return await LoggedInActionResultAsync(iUser, returnUrl, info); - } - - ModelState.AddModelError(string.Empty, S["Invalid login attempt."]); - - return RedirectToLogin(returnUrl); - } - - AddIdentityErrors(identityResult); - } - } - - return View(nameof(RegisterExternalLogin), externalLoginViewModel); - } - } - - return RedirectToLogin(returnUrl); - } - - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public async Task RegisterExternalLogin(RegisterExternalLoginViewModel model, string returnUrl = null) - { - var info = await _signInManager.GetExternalLoginInfoAsync(); - - if (info == null) - { - _logger.LogWarning("Error loading external login info."); - - return NotFound(); - } - - var settings = await _siteService.GetSettingsAsync(); - - if (settings.UsersCanRegister == UserRegistrationType.NoRegistration) - { - _logger.LogWarning("Site does not allow user registration."); - - return NotFound(); - } - - ViewData["ReturnUrl"] = returnUrl; - ViewData["LoginProvider"] = info.LoginProvider; - - model.NoPassword = settings.NoPasswordForExternalUsers; - model.NoEmail = settings.NoEmailForExternalUsers; - model.NoUsername = settings.NoUsernameForExternalUsers; - - ModelState.Clear(); - - if (model.NoEmail && string.IsNullOrWhiteSpace(model.Email)) - { - model.Email = info.Principal.FindFirstValue(ClaimTypes.Email) ?? info.Principal.FindFirstValue("email"); - } - - if (model.NoUsername && string.IsNullOrWhiteSpace(model.UserName)) - { - model.UserName = await GenerateUsernameAsync(info); - } - - if (model.NoPassword) - { - model.Password = null; - model.ConfirmPassword = null; - } - - if (TryValidateModel(model) && ModelState.IsValid) - { - var iUser = await this.RegisterUser( - new RegisterUserForm() - { - UserName = model.UserName, - Email = model.Email, - Password = model.Password, - }, S["Confirm your account"], _logger); - - if (iUser is null) - { - ModelState.AddModelError(string.Empty, "Registration Failed."); - } - else - { - var identityResult = await _signInManager.UserManager.AddLoginAsync(iUser, new UserLoginInfo(info.LoginProvider, info.ProviderKey, info.ProviderDisplayName)); - if (identityResult.Succeeded) - { - _logger.LogInformation(3, "User account linked to {provider} provider.", info.LoginProvider); - - // The login info must be linked before we consider a redirect, or the login info is lost. - if (iUser is User user) - { - if (settings.UsersMustValidateEmail && !user.EmailConfirmed) - { - return RedirectToAction(nameof(EmailConfirmationController.ConfirmEmailSent), - new - { - Area = UserConstants.Features.Users, - Controller = typeof(EmailConfirmationController).ControllerName(), - ReturnUrl = returnUrl, - }); - } - - if (settings.UsersAreModerated && !user.IsEnabled) - { - return RedirectToAction(nameof(RegistrationController.RegistrationPending), - new - { - Area = UserConstants.Features.Users, - Controller = typeof(RegistrationController).ControllerName(), - ReturnUrl = returnUrl, - }); - } - } - - // we have created/linked to the local user, so we must verify the login. If it does not succeed, - // the user is not allowed to login - var signInResult = await ExternalLoginSignInAsync(iUser, info); - if (signInResult.Succeeded) - { - return await LoggedInActionResultAsync(iUser, returnUrl, info); - } - } - AddIdentityErrors(identityResult); - } - } - - return View(nameof(RegisterExternalLogin), model); - } - - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public async Task LinkExternalLogin(LinkExternalLoginViewModel model, string returnUrl = null) - { - var info = await _signInManager.GetExternalLoginInfoAsync(); - - if (info == null) - { - _logger.LogWarning("Error loading external login info."); - - return NotFound(); - } - - var user = await _userManager.FindByEmailAsync(info.GetEmail()); - - if (user == null) - { - _logger.LogWarning("Suspicious login detected from external provider. {provider} with key [{providerKey}] for {identity}", - info.LoginProvider, info.ProviderKey, info.Principal?.Identity?.Name); - - return RedirectToAction(nameof(Login)); - } - - if (ModelState.IsValid) - { - await _accountEvents.InvokeAsync((e, model, modelState) => e.LoggingInAsync(user.UserName, (key, message) => modelState.AddModelError(key, message)), model, ModelState, _logger); - - var signInResult = await _signInManager.CheckPasswordSignInAsync(user, model.Password, false); - if (!signInResult.Succeeded) - { - user = null; - ModelState.AddModelError(string.Empty, S["Invalid login attempt."]); - } - else - { - var identityResult = await _signInManager.UserManager.AddLoginAsync(user, new UserLoginInfo(info.LoginProvider, info.ProviderKey, info.ProviderDisplayName)); - if (identityResult.Succeeded) - { - _logger.LogInformation(3, "User account linked to {provider} provider.", info.LoginProvider); - // we have created/linked to the local user, so we must verify the login. If it does not succeed, - // the user is not allowed to login - if ((await ExternalLoginSignInAsync(user, info)).Succeeded) - { - return await LoggedInActionResultAsync(user, returnUrl, info); - } - } - AddIdentityErrors(identityResult); - } - } - - CopyModelStateErrorsToTempData(null); - - return RedirectToAction(nameof(Login)); - } - - [HttpGet] - public async Task ExternalLogins() - { - var user = await _userManager.GetUserAsync(User); - if (user == null) - { - return Forbid(); - } - - var model = new ExternalLoginsViewModel { CurrentLogins = await _userManager.GetLoginsAsync(user) }; - model.OtherLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()) - .Where(auth => model.CurrentLogins.All(ul => auth.Name != ul.LoginProvider)) - .ToList(); - model.ShowRemoveButton = await _userManager.HasPasswordAsync(user) || model.CurrentLogins.Count > 1; - // model.StatusMessage = StatusMessage; - - CopyTempDataErrorsToModelState(); - - return View(model); - } - - [HttpPost] - [ValidateAntiForgeryToken] - public async Task LinkLogin(string provider) - { - // Clear the existing external cookie to ensure a clean login process. - await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); - - // Request a redirect to the external login provider to link a login for the current user. - var redirectUrl = Url.Action(nameof(LinkLoginCallback)); - var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User)); - - return new ChallengeResult(provider, properties); - } - - [HttpGet] - public async Task LinkLoginCallback() - { - var user = await _userManager.GetUserAsync(User); - if (user == null) - { - _logger.LogError("Unable to load user with ID '{UserId}'.", _userManager.GetUserId(User)); - - return RedirectToAction(nameof(Login)); - } - - var info = await _signInManager.GetExternalLoginInfoAsync(); - if (info == null) - { - _logger.LogError("Unexpected error occurred loading external login info for user '{UserName}'.", user.UserName); - - return RedirectToAction(nameof(Login)); - } - - var result = await _userManager.AddLoginAsync(user, new UserLoginInfo(info.LoginProvider, info.ProviderKey, info.ProviderDisplayName)); - if (!result.Succeeded) - { - _logger.LogError("Unexpected error occurred adding external login info for user '{UserName}'.", user.UserName); - - return RedirectToAction(nameof(Login)); - } - - // Clear the existing external cookie to ensure a clean login process. - await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); - // Perform External Login SignIn. - await ExternalLoginSignInAsync(user, info); - // StatusMessage = "The external login was added."; - - return RedirectToAction(nameof(ExternalLogins)); - } - - [HttpPost] - [ValidateAntiForgeryToken] - public async Task RemoveLogin(RemoveLoginViewModel model) - { - var user = await _userManager.GetUserAsync(User); - if (user == null || user is not User u) - { - _logger.LogError("Unable to load user with ID '{UserId}'.", _userManager.GetUserId(User)); - - return RedirectToAction(nameof(Login)); - } - - var result = await _userManager.RemoveLoginAsync(user, model.LoginProvider, model.ProviderKey); - if (!result.Succeeded) - { - _logger.LogError("Unexpected error occurred removing external login info for user '{UserName}'.", user.UserName); - - return RedirectToAction(nameof(Login)); - } - - // Remove External Authentication Tokens. - foreach (var item in u.UserTokens.Where(c => c.LoginProvider == model.LoginProvider).ToList()) - { - if (!(await _userManager.RemoveAuthenticationTokenAsync(user, model.LoginProvider, item.Name)).Succeeded) - { - _logger.LogError("Could not remove '{TokenName}' token while unlinking '{LoginProvider}' provider from user '{UserName}'.", item.Name, model.LoginProvider, user.UserName); - } - } - - await _signInManager.SignInAsync(user, isPersistent: false); - // StatusMessage = "The external login was removed."; - return RedirectToAction(nameof(ExternalLogins)); - } - - public static async Task UpdateUserPropertiesAsync(UserManager userManager, User user, UpdateUserContext context) - { - await userManager.AddToRolesAsync(user, context.RolesToAdd.Distinct()); - await userManager.RemoveFromRolesAsync(user, context.RolesToRemove.Distinct()); - - var userNeedUpdate = false; - if (context.PropertiesToUpdate != null) - { - var currentProperties = user.Properties.DeepClone(); - user.Properties.Merge(context.PropertiesToUpdate, _jsonMergeSettings); - userNeedUpdate = !JsonNode.DeepEquals(currentProperties, user.Properties); - } - - var currentClaims = user.UserClaims - .Where(x => !string.IsNullOrEmpty(x.ClaimType)) - .DistinctBy(x => new { x.ClaimType, x.ClaimValue }) - .ToList(); - - var claimsChanged = false; - if (context.ClaimsToRemove?.Count > 0) - { - var claimsToRemove = context.ClaimsToRemove.ToHashSet(); - foreach (var item in claimsToRemove) - { - var exists = currentClaims.FirstOrDefault(claim => claim.ClaimType == item.ClaimType && claim.ClaimValue == item.ClaimValue); - if (exists is not null) - { - currentClaims.Remove(exists); - claimsChanged = true; - } - } - } - - if (context.ClaimsToUpdate?.Count > 0) - { - foreach (var item in context.ClaimsToUpdate) - { - var existing = currentClaims.FirstOrDefault(claim => claim.ClaimType == item.ClaimType && claim.ClaimValue == item.ClaimValue); - if (existing is null) - { - currentClaims.Add(item); - claimsChanged = true; - } - } - } - - if (claimsChanged) - { - user.UserClaims = currentClaims; - userNeedUpdate = true; - } - - return userNeedUpdate; - } - - private async Task GenerateUsernameAsync(ExternalLoginInfo info) - { - var ret = string.Concat("u", IdGenerator.GenerateId()); - var externalClaims = info?.Principal.GetSerializableClaims(); - var userNames = new Dictionary(); - - foreach (var item in _externalLoginHandlers) - { - try - { - var userName = await item.GenerateUserName(info.LoginProvider, externalClaims.ToArray()); - if (!string.IsNullOrWhiteSpace(userName)) - { - // Set the return value to the username generated by the first IExternalLoginHandler. - if (userNames.Count == 0) - { - ret = userName; - } - userNames.Add(item.GetType(), userName); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "{externalLoginHandler} - IExternalLoginHandler.GenerateUserName threw an exception", item.GetType()); - } - } - - if (userNames.Count > 1) - { - _logger.LogWarning("More than one IExternalLoginHandler generated username. Used first one registered, {externalLoginHandler}", userNames.FirstOrDefault().Key); - } - - return ret; - } - - private RedirectToActionResult RedirectToLogin(string returnUrl) - { - CopyModelStateErrorsToTempData(); - - return RedirectToAction(nameof(Login), new { returnUrl }); - } - - private void CopyModelStateErrorsToTempData(string key = "") - { - var iix = 0; - - foreach (var state in ModelState) - { - if (key != null && state.Key != key) - { - continue; - } - - foreach (var item in state.Value.Errors) - { - TempData[$"error_{iix++}"] = item.ErrorMessage; - } - } - } - - private void CopyTempDataErrorsToModelState() - { - foreach (var errorMessage in TempData.Where(x => x.Key.StartsWith("error")).Select(x => x.Value.ToString())) - { - ModelState.AddModelError(string.Empty, errorMessage); - } - } - - private bool AddUserEnabledError(IUser user) - { - if (user is not User localUser || !localUser.IsEnabled) - { - ModelState.AddModelError(string.Empty, S["The specified user is not allowed to sign in."]); - - return true; - } - - return false; - } + [Obsolete("This method will be removed in version 3. Instead please use UserManagerHelper.UpdateUserPropertiesAsync(userManager, user, context).")] + public static Task UpdateUserPropertiesAsync(UserManager userManager, User user, UpdateUserContext context) + => UserManagerHelper.UpdateUserPropertiesAsync(userManager, user, context); private async Task AddConfirmEmailErrorAsync(IUser user) { - var registrationFeatureIsAvailable = (await _shellFeaturesManager.GetAvailableFeaturesAsync()) - .Any(feature => feature.Id == UserConstants.Features.UserRegistration); - - if (!registrationFeatureIsAvailable) - { - return false; - } - - var registrationSettings = await _siteService.GetSettingsAsync(); - if (registrationSettings.UsersMustValidateEmail) + if (_registrationOptions.UsersMustValidateEmail) { // Require that the users have a confirmed email before they can log on. if (!await _userManager.IsEmailConfirmedAsync(user)) { ModelState.AddModelError(string.Empty, S["You must confirm your email."]); + return true; } } return false; } - - private void AddIdentityErrors(IdentityResult result) - { - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ExternalAuthenticationsController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ExternalAuthenticationsController.cs new file mode 100644 index 00000000000..546b3a6e645 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ExternalAuthenticationsController.cs @@ -0,0 +1,703 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Environment.Shell; +using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Settings; +using OrchardCore.Users.Events; +using OrchardCore.Users.Handlers; +using OrchardCore.Users.Models; +using OrchardCore.Users.ViewModels; + +namespace OrchardCore.Users.Controllers; + +[Feature(UserConstants.Features.ExternalAuthentication)] +public sealed class ExternalAuthenticationsController : AccountBaseController +{ + public const string DefaultExternalLoginProtector = "DefaultExternalLogin"; + + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly ILogger _logger; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly IDistributedCache _distributedCache; + private readonly ISiteService _siteService; + private readonly IEnumerable _accountEvents; + private readonly IShellFeaturesManager _shellFeaturesManager; + private readonly INotifier _notifier; + private readonly IEnumerable _externalLoginHandlers; + private readonly ExternalLoginOptions _externalLoginOption; + private readonly IdentityOptions _identityOptions; + + internal readonly IHtmlLocalizer H; + internal readonly IStringLocalizer S; + + public ExternalAuthenticationsController( + SignInManager signInManager, + UserManager userManager, + ILogger logger, + IDataProtectionProvider dataProtectionProvider, + IDistributedCache distributedCache, + ISiteService siteService, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer, + IEnumerable accountEvents, + IShellFeaturesManager shellFeaturesManager, + INotifier notifier, + IEnumerable externalLoginHandlers, + IOptions externalLoginOption, + IOptions identityOptions) + { + _signInManager = signInManager; + _userManager = userManager; + _logger = logger; + _dataProtectionProvider = dataProtectionProvider; + _distributedCache = distributedCache; + _siteService = siteService; + _accountEvents = accountEvents; + _shellFeaturesManager = shellFeaturesManager; + _notifier = notifier; + _externalLoginHandlers = externalLoginHandlers; + _externalLoginOption = externalLoginOption.Value; + _identityOptions = identityOptions.Value; + + H = htmlLocalizer; + S = stringLocalizer; + } + + [AllowAnonymous] + public async Task DefaultExternalLogin(string protectedToken, string returnUrl = null) + { + if (_externalLoginOption.UseExternalProviderIfOnlyOneDefined) + { + var schemes = await _signInManager.GetExternalAuthenticationSchemesAsync(); + if (schemes.Count() == 1) + { + var dataProtector = _dataProtectionProvider.CreateProtector(DefaultExternalLoginProtector) + .ToTimeLimitedDataProtector(); + + try + { + if (Guid.TryParse(dataProtector.Unprotect(protectedToken), out var token)) + { + var tokenBytes = await _distributedCache.GetAsync(token.ToString()); + var cacheToken = new Guid(tokenBytes); + if (token.Equals(cacheToken)) + { + return ExternalLogin(schemes.First().Name, returnUrl); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while validating {defaultExternalLogin} token", DefaultExternalLoginProtector); + } + } + } + + return RedirectToLogin(); + } + + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public IActionResult ExternalLogin(string provider, string returnUrl = null) + { + // Request a redirect to the external login provider. + var redirectUrl = Url.Action(nameof(ExternalLoginCallback), new { returnUrl }); + var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); + + return Challenge(properties, provider); + } + + [AllowAnonymous] + public async Task ExternalLoginCallback(string returnUrl = null, string remoteError = null) + { + if (remoteError != null) + { + _logger.LogError("Error from external provider: {Error}", remoteError); + ModelState.AddModelError(string.Empty, S["An error occurred in external provider."]); + + return RedirectToLogin(returnUrl); + } + + var info = await _signInManager.GetExternalLoginInfoAsync(); + if (info == null) + { + _logger.LogError("Could not get external login info."); + ModelState.AddModelError(string.Empty, S["An error occurred in external provider."]); + + return RedirectToLogin(returnUrl); + } + + var iUser = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey); + + CopyTempDataErrorsToModelState(); + + if (iUser != null) + { + if (!await AddConfirmEmailErrorAsync(iUser) && !AddUserEnabledError(iUser, S)) + { + await _accountEvents.InvokeAsync((e, user, modelState) => e.LoggingInAsync(user.UserName, (key, message) => modelState.AddModelError(key, message)), iUser, ModelState, _logger); + + var signInResult = await ExternalLoginSignInAsync(iUser, info); + if (signInResult.Succeeded) + { + return await LoggedInActionResultAsync(iUser, returnUrl, info); + } + else + { + ModelState.AddModelError(string.Empty, S["Invalid login attempt."]); + } + } + } + else + { + var email = info.GetEmail(); + + if (_identityOptions.User.RequireUniqueEmail && !string.IsNullOrWhiteSpace(email)) + { + iUser = await _userManager.FindByEmailAsync(email); + } + + ViewData["ReturnUrl"] = returnUrl; + ViewData["LoginProvider"] = info.LoginProvider; + + var registrationSettings = await _siteService.GetSettingsAsync(); + + if (iUser != null) + { + if (iUser is User userToLink && registrationSettings.UsersMustValidateEmail && !userToLink.EmailConfirmed) + { + return RedirectToAction(nameof(EmailConfirmationController.ConfirmEmailSent), + new + { + Area = UserConstants.Features.Users, + Controller = typeof(EmailConfirmationController).ControllerName(), + ReturnUrl = returnUrl, + }); + } + + // Link external login to an existing user. + ViewData["UserName"] = iUser.UserName; + + return View(nameof(LinkExternalLogin)); + } + + var settings = await _siteService.GetSettingsAsync(); + + var externalLoginViewModel = new RegisterExternalLoginViewModel + { + NoPassword = settings.NoPassword, + NoEmail = settings.NoEmail, + NoUsername = settings.NoUsername, + + UserName = await GenerateUsernameAsync(info), + Email = info.GetEmail(), + }; + + // The user doesn't exist and no information required, we can create the account locally + // instead of redirecting to the ExternalLogin. + var noInformationRequired = externalLoginViewModel.NoPassword + && externalLoginViewModel.NoEmail + && externalLoginViewModel.NoUsername; + + if (noInformationRequired) + { + iUser = await this.RegisterUser(new RegisterUserForm() + { + UserName = externalLoginViewModel.UserName, + Email = externalLoginViewModel.Email, + Password = null, + }, S["Confirm your account"], _logger); + + // If the registration was successful we can link the external provider and redirect the user. + if (iUser != null) + { + var identityResult = await _signInManager.UserManager.AddLoginAsync(iUser, new UserLoginInfo(info.LoginProvider, info.ProviderKey, info.ProviderDisplayName)); + if (identityResult.Succeeded) + { + _logger.LogInformation(3, "User account linked to {LoginProvider} provider.", info.LoginProvider); + + // The login info must be linked before we consider a redirect, or the login info is lost. + if (iUser is User user) + { + if (registrationSettings.UsersMustValidateEmail && !user.EmailConfirmed) + { + return RedirectToAction(nameof(EmailConfirmationController.ConfirmEmailSent), + new + { + Area = UserConstants.Features.Users, + Controller = typeof(EmailConfirmationController).ControllerName(), + ReturnUrl = returnUrl, + }); + } + + if (registrationSettings.UsersAreModerated && !user.IsEnabled) + { + return RedirectToAction(nameof(RegistrationController.RegistrationPending), + new + { + Area = UserConstants.Features.Users, + Controller = typeof(RegistrationController).ControllerName(), + ReturnUrl = returnUrl, + }); + } + } + + // We have created/linked to the local user, so we must verify the login. + // If it does not succeed, the user is not allowed to login + var signInResult = await ExternalLoginSignInAsync(iUser, info); + if (signInResult.Succeeded) + { + return await LoggedInActionResultAsync(iUser, returnUrl, info); + } + + ModelState.AddModelError(string.Empty, S["Invalid login attempt."]); + + return RedirectToLogin(returnUrl); + } + + AddIdentityErrors(identityResult); + } + } + + if (settings.DisableNewRegistrations) + { + await _notifier.ErrorAsync(H["New registrations are disabled for this site."]); + + return RedirectToLogin(); + } + + return View(nameof(RegisterExternalLogin), externalLoginViewModel); + } + + return RedirectToLogin(returnUrl); + } + + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task RegisterExternalLogin(RegisterExternalLoginViewModel model, string returnUrl = null) + { + var settings = await _siteService.GetSettingsAsync(); + + if (settings.DisableNewRegistrations) + { + await _notifier.ErrorAsync(H["New registrations are disabled for this site."]); + + return RedirectToLogin(); + } + + var info = await _signInManager.GetExternalLoginInfoAsync(); + + if (info == null) + { + _logger.LogWarning("Error loading external login info."); + + return NotFound(); + } + + ViewData["ReturnUrl"] = returnUrl; + ViewData["LoginProvider"] = info.LoginProvider; + + model.NoPassword = settings.NoPassword; + model.NoEmail = settings.NoEmail; + model.NoUsername = settings.NoUsername; + + ModelState.Clear(); + + if (model.NoEmail && string.IsNullOrWhiteSpace(model.Email)) + { + model.Email = info.Principal.FindFirstValue(ClaimTypes.Email) ?? info.Principal.FindFirstValue("email"); + } + + if (model.NoUsername && string.IsNullOrWhiteSpace(model.UserName)) + { + model.UserName = await GenerateUsernameAsync(info); + } + + if (model.NoPassword) + { + model.Password = null; + model.ConfirmPassword = null; + } + + if (TryValidateModel(model) && ModelState.IsValid) + { + var iUser = await this.RegisterUser( + new RegisterUserForm() + { + UserName = model.UserName, + Email = model.Email, + Password = model.Password, + }, S["Confirm your account"], _logger); + + if (iUser is null) + { + ModelState.AddModelError(string.Empty, "Registration Failed."); + } + else + { + var identityResult = await _signInManager.UserManager.AddLoginAsync(iUser, new UserLoginInfo(info.LoginProvider, info.ProviderKey, info.ProviderDisplayName)); + if (identityResult.Succeeded) + { + _logger.LogInformation(3, "User account linked to {provider} provider.", info.LoginProvider); + + // The login info must be linked before we consider a redirect, or the login info is lost. + if (iUser is User user) + { + var registrationSettings = await _siteService.GetSettingsAsync(); + + if (registrationSettings.UsersMustValidateEmail && !user.EmailConfirmed) + { + return RedirectToAction(nameof(EmailConfirmationController.ConfirmEmailSent), + new + { + Area = UserConstants.Features.Users, + Controller = typeof(EmailConfirmationController).ControllerName(), + ReturnUrl = returnUrl, + }); + } + + if (registrationSettings.UsersAreModerated && !user.IsEnabled) + { + return RedirectToAction(nameof(RegistrationController.RegistrationPending), + new + { + Area = UserConstants.Features.Users, + Controller = typeof(RegistrationController).ControllerName(), + ReturnUrl = returnUrl, + }); + } + } + + // we have created/linked to the local user, so we must verify the login. If it does not succeed, + // the user is not allowed to login + var signInResult = await ExternalLoginSignInAsync(iUser, info); + if (signInResult.Succeeded) + { + return await LoggedInActionResultAsync(iUser, returnUrl, info); + } + } + + AddIdentityErrors(identityResult); + } + } + + return View(model); + } + + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task LinkExternalLogin(LinkExternalLoginViewModel model, string returnUrl = null) + { + var info = await _signInManager.GetExternalLoginInfoAsync(); + + if (info == null) + { + _logger.LogWarning("Error loading external login info."); + + return NotFound(); + } + + var user = await _userManager.FindByEmailAsync(info.GetEmail()); + + if (user == null) + { + _logger.LogWarning("Suspicious login detected from external provider. {provider} with key [{providerKey}] for {identity}", + info.LoginProvider, info.ProviderKey, info.Principal?.Identity?.Name); + + return RedirectToLogin(); + } + + if (ModelState.IsValid) + { + await _accountEvents.InvokeAsync((e, model, modelState) => e.LoggingInAsync(user.UserName, (key, message) => modelState.AddModelError(key, message)), model, ModelState, _logger); + + var signInResult = await _signInManager.CheckPasswordSignInAsync(user, model.Password, false); + if (!signInResult.Succeeded) + { + user = null; + ModelState.AddModelError(string.Empty, S["Invalid login attempt."]); + } + else + { + var identityResult = await _signInManager.UserManager.AddLoginAsync(user, new UserLoginInfo(info.LoginProvider, info.ProviderKey, info.ProviderDisplayName)); + if (identityResult.Succeeded) + { + _logger.LogInformation(3, "User account linked to {provider} provider.", info.LoginProvider); + // we have created/linked to the local user, so we must verify the login. If it does not succeed, + // the user is not allowed to login. + if ((await ExternalLoginSignInAsync(user, info)).Succeeded) + { + return await LoggedInActionResultAsync(user, returnUrl, info); + } + } + AddIdentityErrors(identityResult); + } + } + + CopyModelStateErrorsToTempData(null); + + return RedirectToLogin(); + } + + public async Task ExternalLogins() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return Forbid(); + } + + var model = new ExternalLoginsViewModel + { + CurrentLogins = await _userManager.GetLoginsAsync(user), + }; + model.ShowRemoveButton = await _userManager.HasPasswordAsync(user) || model.CurrentLogins.Count > 1; + model.OtherLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()) + .Where(auth => model.CurrentLogins.All(ul => auth.Name != ul.LoginProvider)) + .ToArray(); + + CopyTempDataErrorsToModelState(); + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task LinkLogin(string provider) + { + // Clear the existing external cookie to ensure a clean login process. + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + // Request a redirect to the external login provider to link a login for the current user. + var redirectUrl = Url.Action(nameof(LinkLoginCallback)); + var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User)); + + return new ChallengeResult(provider, properties); + } + + public async Task LinkLoginCallback() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + _logger.LogError("Unable to load user with ID '{UserId}'.", _userManager.GetUserId(User)); + + return RedirectToLogin(); + } + + var info = await _signInManager.GetExternalLoginInfoAsync(); + if (info == null) + { + _logger.LogError("Unexpected error occurred loading external login info for user '{UserName}'.", user.UserName); + + return RedirectToLogin(); + } + + var result = await _userManager.AddLoginAsync(user, new UserLoginInfo(info.LoginProvider, info.ProviderKey, info.ProviderDisplayName)); + if (!result.Succeeded) + { + _logger.LogError("Unexpected error occurred adding external login info for user '{UserName}'.", user.UserName); + + return RedirectToLogin(); + } + + // Clear the existing external cookie to ensure a clean login process. + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + // Perform External Login SignIn. + await ExternalLoginSignInAsync(user, info); + + return RedirectToAction(nameof(ExternalLogins)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RemoveLogin(RemoveLoginViewModel model) + { + var user = await _userManager.GetUserAsync(User); + if (user == null || user is not User u) + { + _logger.LogError("Unable to load user with ID '{UserId}'.", _userManager.GetUserId(User)); + + return RedirectToLogin(); + } + + var result = await _userManager.RemoveLoginAsync(user, model.LoginProvider, model.ProviderKey); + if (!result.Succeeded) + { + _logger.LogError("Unexpected error occurred removing external login info for user '{UserName}'.", user.UserName); + + return RedirectToLogin(); + } + + // Remove External Authentication Tokens. + foreach (var item in u.UserTokens.Where(c => c.LoginProvider == model.LoginProvider).ToList()) + { + if (!(await _userManager.RemoveAuthenticationTokenAsync(user, model.LoginProvider, item.Name)).Succeeded) + { + _logger.LogError("Could not remove '{TokenName}' token while unlinking '{LoginProvider}' provider from user '{UserName}'.", item.Name, model.LoginProvider, user.UserName); + } + } + + await _signInManager.SignInAsync(user, isPersistent: false); + + return RedirectToAction(nameof(ExternalLogins)); + } + + private async Task ExternalLoginSignInAsync(IUser user, ExternalLoginInfo info) + { + var externalClaims = info.Principal.GetSerializableClaims(); + var userRoles = await _userManager.GetRolesAsync(user); + var userInfo = user as User; + + var context = new UpdateUserContext(user, info.LoginProvider, externalClaims, userInfo.Properties) + { + UserClaims = userInfo.UserClaims, + UserRoles = userRoles, + }; + foreach (var item in _externalLoginHandlers) + { + try + { + await item.UpdateUserAsync(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "{ExternalLoginHandler}.UpdateUserAsync threw an exception", item.GetType()); + } + } + + if (await UserManagerHelper.UpdateUserPropertiesAsync(_userManager, userInfo, context)) + { + await _userManager.UpdateAsync(user); + } + + var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true); + + if (result.Succeeded) + { + await _accountEvents.InvokeAsync((e, user) => e.LoggedInAsync(user), user, _logger); + + var identityResult = await _signInManager.UpdateExternalAuthenticationTokensAsync(info); + if (!identityResult.Succeeded) + { + _logger.LogError("Error updating the external authentication tokens."); + } + } + else + { + await _accountEvents.InvokeAsync((e, user) => e.LoggingInFailedAsync(user), user, _logger); + } + + return result; + } + + private void AddIdentityErrors(IdentityResult result) + { + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + } + + private RedirectToActionResult RedirectToLogin(string returnUrl) + { + CopyModelStateErrorsToTempData(); + + return RedirectToAction(nameof(AccountController.Login), typeof(AccountController).ControllerName(), new { returnUrl }); + } + + private RedirectToActionResult RedirectToLogin() + => RedirectToAction(nameof(AccountController.Login), typeof(AccountController).ControllerName()); + + private void CopyModelStateErrorsToTempData(string key = "") + { + var iix = 0; + + foreach (var state in ModelState) + { + if (key != null && state.Key != key) + { + continue; + } + + foreach (var item in state.Value.Errors) + { + TempData[$"error_{iix++}"] = item.ErrorMessage; + } + } + } + + private async Task AddConfirmEmailErrorAsync(IUser user) + { + var registrationFeatureIsAvailable = (await _shellFeaturesManager.GetAvailableFeaturesAsync()) + .Any(feature => feature.Id == UserConstants.Features.UserRegistration); + + if (!registrationFeatureIsAvailable) + { + return false; + } + + var registrationSettings = await _siteService.GetSettingsAsync(); + if (registrationSettings.UsersMustValidateEmail) + { + // Require that the users have a confirmed email before they can log on. + if (!await _userManager.IsEmailConfirmedAsync(user)) + { + ModelState.AddModelError(string.Empty, S["You must confirm your email."]); + return true; + } + } + + return false; + } + + private async Task GenerateUsernameAsync(ExternalLoginInfo info) + { + var ret = string.Concat("u", IdGenerator.GenerateId()); + var externalClaims = info?.Principal.GetSerializableClaims(); + var userNames = new Dictionary(); + + foreach (var item in _externalLoginHandlers) + { + try + { + var userName = await item.GenerateUserName(info.LoginProvider, externalClaims.ToArray()); + if (!string.IsNullOrWhiteSpace(userName)) + { + // Set the return value to the username generated by the first IExternalLoginHandler. + if (userNames.Count == 0) + { + ret = userName; + } + userNames.Add(item.GetType(), userName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "{externalLoginHandler} - IExternalLoginHandler.GenerateUserName threw an exception", item.GetType()); + } + } + + if (userNames.Count > 1) + { + _logger.LogWarning("More than one IExternalLoginHandler generated username. Used first one registered, {externalLoginHandler}", userNames.FirstOrDefault().Key); + } + + return ret; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/RegistrationController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/RegistrationController.cs index 15fbeec174a..3c9892cc95c 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/RegistrationController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/RegistrationController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using OrchardCore.DisplayManagement; using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.DisplayManagement.Notify; @@ -23,6 +24,7 @@ public sealed class RegistrationController : Controller private readonly INotifier _notifier; private readonly ILogger _logger; private readonly IDisplayManager _registerUserDisplayManager; + private readonly RegistrationOptions _registrationOptions; private readonly IUpdateModelAccessor _updateModelAccessor; internal readonly IStringLocalizer S; @@ -35,6 +37,7 @@ public RegistrationController( INotifier notifier, ILogger logger, IDisplayManager registerUserDisplayManager, + IOptions registrationOptions, IUpdateModelAccessor updateModelAccessor, IHtmlLocalizer htmlLocalizer, IStringLocalizer stringLocalizer) @@ -45,6 +48,7 @@ public RegistrationController( _notifier = notifier; _logger = logger; _registerUserDisplayManager = registerUserDisplayManager; + _registrationOptions = registrationOptions.Value; _updateModelAccessor = updateModelAccessor; H = htmlLocalizer; S = stringLocalizer; @@ -53,12 +57,6 @@ public RegistrationController( [AllowAnonymous] public async Task Register(string returnUrl = null) { - var settings = await _siteService.GetSettingsAsync(); - if (settings.UsersCanRegister != UserRegistrationType.AllowRegistration) - { - return NotFound(); - } - var shape = await _registerUserDisplayManager.BuildEditorAsync(_updateModelAccessor.ModelUpdater, false, string.Empty, string.Empty); ViewData["ReturnUrl"] = returnUrl; @@ -72,13 +70,6 @@ public async Task Register(string returnUrl = null) [ActionName(nameof(Register))] public async Task RegisterPOST(string returnUrl = null) { - var settings = await _siteService.GetSettingsAsync(); - - if (settings.UsersCanRegister != UserRegistrationType.AllowRegistration) - { - return NotFound(); - } - var model = new RegisterUserForm(); var shape = await _registerUserDisplayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, false, string.Empty, string.Empty); @@ -89,15 +80,15 @@ public async Task RegisterPOST(string returnUrl = null) { var iUser = await this.RegisterUser(model, S["Confirm your account"], _logger); - // If we get a user, redirect to returnUrl + // If we get a user, redirect to returnUrl. if (iUser is User user) { - if (settings.UsersMustValidateEmail && !user.EmailConfirmed) + if (_registrationOptions.UsersMustValidateEmail && !user.EmailConfirmed) { return RedirectToAction(nameof(EmailConfirmationController.ConfirmEmailSent), typeof(EmailConfirmationController).ControllerName(), new { ReturnUrl = returnUrl }); } - if (settings.UsersAreModerated && !user.IsEnabled) + if (_registrationOptions.UsersAreModerated && !user.IsEnabled) { return RedirectToAction(nameof(RegistrationPending), new { ReturnUrl = returnUrl }); } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/DataMigrations/ExternalAuthenticationMigrations.cs b/src/OrchardCore.Modules/OrchardCore.Users/DataMigrations/ExternalAuthenticationMigrations.cs new file mode 100644 index 00000000000..99a71e3eff2 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/DataMigrations/ExternalAuthenticationMigrations.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Data.Migration; +using OrchardCore.Entities; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Scope; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.DataMigrations; + +public sealed class ExternalAuthenticationMigrations : DataMigration +{ + public int Create() + { + ShellScope.AddDeferredTask(async scope => + { + var featuresManager = scope.ServiceProvider.GetRequiredService(); + + var isRegistrationFeatureEnabled = await featuresManager.IsFeatureEnabledAsync(UserConstants.Features.UserRegistration); + + var siteService = scope.ServiceProvider.GetRequiredService(); + + var site = await siteService.LoadSiteSettingsAsync(); + + var registrationSettings = site.Properties[nameof(RegistrationSettings)]?.AsObject() ?? new JsonObject(); + + var enumValue = registrationSettings["UsersCanRegister"]?.GetValue(); + + site.Put(new ExternalRegistrationSettings + { + DisableNewRegistrations = enumValue == 0 || !isRegistrationFeatureEnabled, + NoUsername = registrationSettings["NoUsernameForExternalUsers"]?.GetValue() ?? false, + NoEmail = registrationSettings["NoEmailForExternalUsers"]?.GetValue() ?? false, + NoPassword = registrationSettings["NoPasswordForExternalUsers"]?.GetValue() ?? false, + GenerateUsernameScript = registrationSettings["GenerateUsernameScript"]?.ToString(), + UseScriptToGenerateUsername = registrationSettings["UseScriptToGenerateUsername"]?.GetValue() ?? false, + }); + + var loginSettings = site.Properties[nameof(LoginSettings)]?.AsObject() ?? new JsonObject(); + + site.Put(new ExternalLoginSettings + { + UseExternalProviderIfOnlyOneDefined = loginSettings["UseExternalProviderIfOnlyOneDefined"]?.GetValue() ?? false, + UseScriptToSyncProperties = loginSettings["UseScriptToSyncRoles"]?.GetValue() ?? false, + SyncPropertiesScript = loginSettings["SyncRolesScript"]?.ToString(), + }); + + await siteService.UpdateSiteSettingsAsync(site); + + if (enumValue is not null && enumValue != 1) + { + if (!isRegistrationFeatureEnabled) + { + return; + } + + await featuresManager.DisableFeaturesAsync(UserConstants.Features.UserRegistration); + } + }); + + return 1; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ExternalAuthenticationUserMenuDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ExternalAuthenticationUserMenuDisplayDriver.cs new file mode 100644 index 00000000000..b3c27c0f82a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ExternalAuthenticationUserMenuDisplayDriver.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Identity; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.Drivers; + +public sealed class ExternalAuthenticationUserMenuDisplayDriver : DisplayDriver +{ + private readonly SignInManager _signInManager; + + public ExternalAuthenticationUserMenuDisplayDriver(SignInManager signInManager) + { + _signInManager = signInManager; + } + + public override IDisplayResult Display(UserMenu model, BuildDisplayContext context) + { + return View("UserMenuItems__ExternalLogins", model) + .RenderWhen(async () => (await _signInManager.GetExternalAuthenticationSchemesAsync()).Any()) + .Location("Detail", "Content:10") + .Location("DetailAdmin", "Content:10") + .Differentiator("ExternalLogins"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ExternalLoginSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ExternalLoginSettingsDisplayDriver.cs new file mode 100644 index 00000000000..7b846bed40b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ExternalLoginSettingsDisplayDriver.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Environment.Shell; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.Drivers; + +public sealed class ExternalLoginSettingsDisplayDriver : SiteDisplayDriver +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + private readonly IShellReleaseManager _shellReleaseManager; + + public ExternalLoginSettingsDisplayDriver( + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IShellReleaseManager shellReleaseManager) + { + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + _shellReleaseManager = shellReleaseManager; + } + + protected override string SettingsGroupId + => LoginSettingsDisplayDriver.GroupId; + + public override IDisplayResult Edit(ISite site, ExternalLoginSettings settings, BuildEditorContext context) + { + return Initialize("ExternalLoginSettings_Edit", model => + { + model.UseExternalProviderIfOnlyOneDefined = settings.UseExternalProviderIfOnlyOneDefined; + model.UseScriptToSyncProperties = settings.UseScriptToSyncProperties; + model.SyncPropertiesScript = settings.SyncPropertiesScript; + }).Location("Content:5#External Login;10") + .RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, CommonPermissions.ManageUsers)) + .OnGroup(SettingsGroupId); + } + + public override async Task UpdateAsync(ISite site, ExternalLoginSettings settings, UpdateEditorContext context) + { + if (!await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, CommonPermissions.ManageUsers)) + { + return null; + } + + var valueBefore = settings.UseExternalProviderIfOnlyOneDefined; + + await context.Updater.TryUpdateModelAsync(settings, Prefix); + + if (valueBefore != settings.UseExternalProviderIfOnlyOneDefined) + { + _shellReleaseManager.RequestRelease(); + } + + return Edit(site, settings, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ExternalRegistrationSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ExternalRegistrationSettingsDisplayDriver.cs new file mode 100644 index 00000000000..99b1d14a80b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ExternalRegistrationSettingsDisplayDriver.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.Drivers; + +public sealed class ExternalRegistrationSettingsDisplayDriver : SiteDisplayDriver +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + + public ExternalRegistrationSettingsDisplayDriver( + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService) + { + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + } + + protected override string SettingsGroupId + => RegistrationSettingsDisplayDriver.GroupId; + + public override async Task EditAsync(ISite site, ExternalRegistrationSettings settings, BuildEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (!await _authorizationService.AuthorizeAsync(user, CommonPermissions.ManageUsers)) + { + return null; + } + + return Initialize("ExternalRegistrationSettings_Edit", model => + { + model.DisableNewRegistrations = settings.DisableNewRegistrations; + model.NoPassword = settings.NoPassword; + model.NoUsername = settings.NoUsername; + model.NoEmail = settings.NoEmail; + model.UseScriptToGenerateUsername = settings.UseScriptToGenerateUsername; + model.GenerateUsernameScript = settings.GenerateUsernameScript; + }).Location("Content:5#External Authentication;5") + .OnGroup(SettingsGroupId); + } + + public override async Task UpdateAsync(ISite site, ExternalRegistrationSettings settings, UpdateEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (!await _authorizationService.AuthorizeAsync(user, CommonPermissions.ManageUsers)) + { + return null; + } + + await context.Updater.TryUpdateModelAsync(settings, Prefix); + + return await EditAsync(site, settings, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginSettingsDisplayDriver.cs index f4fa63a747c..9decddac8ed 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginSettingsDisplayDriver.cs @@ -31,10 +31,7 @@ public override IDisplayResult Edit(ISite site, LoginSettings settings, BuildEdi return Initialize("LoginSettings_Edit", model => { model.UseSiteTheme = settings.UseSiteTheme; - model.UseExternalProviderIfOnlyOneDefined = settings.UseExternalProviderIfOnlyOneDefined; model.DisableLocalLogin = settings.DisableLocalLogin; - model.UseScriptToSyncRoles = settings.UseScriptToSyncRoles; - model.SyncRolesScript = settings.SyncRolesScript; model.AllowChangingEmail = settings.AllowChangingEmail; model.AllowChangingUsername = settings.AllowChangingUsername; model.AllowChangingPhoneNumber = settings.AllowChangingPhoneNumber; diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs index f3cbd90c902..c50ce421d16 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs @@ -1,28 +1,13 @@ using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; -using OrchardCore.Settings; using OrchardCore.Users.Models; namespace OrchardCore.Users.Drivers; public sealed class RegisterUserLoginFormDisplayDriver : DisplayDriver { - private readonly ISiteService _siteService; - - public RegisterUserLoginFormDisplayDriver(ISiteService siteService) - { - _siteService = siteService; - } - - public override async Task EditAsync(LoginForm model, BuildEditorContext context) + public override IDisplayResult Edit(LoginForm model, BuildEditorContext context) { - var settings = await _siteService.GetSettingsAsync(); - - if (settings.UsersCanRegister != UserRegistrationType.AllowRegistration) - { - return null; - } - return View("LoginFormRegisterUser", model).Location("Links:10"); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegistrationSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegistrationSettingsDisplayDriver.cs index cc411c313b1..2b15c57ab6e 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegistrationSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegistrationSettingsDisplayDriver.cs @@ -3,6 +3,7 @@ using OrchardCore.DisplayManagement.Entities; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; +using OrchardCore.Environment.Shell; using OrchardCore.Settings; using OrchardCore.Users.Models; @@ -14,13 +15,16 @@ public sealed class RegistrationSettingsDisplayDriver : SiteDisplayDriver EditAsync(ISite site, RegistrationSet return null; } + context.AddTenantReloadWarningWrapper(); + return Initialize("RegistrationSettings_Edit", model => { - model.UsersCanRegister = settings.UsersCanRegister; model.UsersMustValidateEmail = settings.UsersMustValidateEmail; model.UsersAreModerated = settings.UsersAreModerated; model.UseSiteTheme = settings.UseSiteTheme; - model.NoPasswordForExternalUsers = settings.NoPasswordForExternalUsers; - model.NoUsernameForExternalUsers = settings.NoUsernameForExternalUsers; - model.NoEmailForExternalUsers = settings.NoEmailForExternalUsers; - model.UseScriptToGenerateUsername = settings.UseScriptToGenerateUsername; - model.GenerateUsernameScript = settings.GenerateUsernameScript; }).Location("Content:5") .OnGroup(SettingsGroupId); } @@ -59,7 +59,22 @@ public override async Task UpdateAsync(ISite site, RegistrationS return null; } - await context.Updater.TryUpdateModelAsync(settings, Prefix); + var model = new RegistrationSettings(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + var hasChange = model.UsersMustValidateEmail != settings.UsersMustValidateEmail + || model.UsersAreModerated != settings.UsersAreModerated + || model.UseSiteTheme != model.UseSiteTheme; + + settings.UsersMustValidateEmail = model.UsersMustValidateEmail; + settings.UsersAreModerated = model.UsersAreModerated; + settings.UseSiteTheme = model.UseSiteTheme; + + if (hasChange) + { + _shellReleaseManager.RequestRelease(); + } return await EditAsync(site, settings, context); } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/UserMenuDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/UserMenuDisplayDriver.cs index 9385aefe048..68fe0a1d66b 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/UserMenuDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/UserMenuDisplayDriver.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; using OrchardCore.Users.Models; @@ -8,14 +7,10 @@ namespace OrchardCore.Users.Drivers; public sealed class UserMenuDisplayDriver : DisplayDriver { - private readonly SignInManager _signInManager; private readonly IHttpContextAccessor _httpContextAccessor; - public UserMenuDisplayDriver( - SignInManager signInManager, - IHttpContextAccessor httpContextAccessor) + public UserMenuDisplayDriver(IHttpContextAccessor httpContextAccessor) { - _signInManager = signInManager; _httpContextAccessor = httpContextAccessor; } @@ -37,12 +32,6 @@ public override Task DisplayAsync(UserMenu model, BuildDisplayCo .Location("DetailAdmin", "Content:5") .Differentiator("Profile"), - View("UserMenuItems__ExternalLogins", model) - .RenderWhen(async () => (await _signInManager.GetExternalAuthenticationSchemesAsync()).Any()) - .Location("Detail", "Content:10") - .Location("DetailAdmin", "Content:10") - .Differentiator("ExternalLogins"), - View("UserMenuItems__SignOut", model) .Location("Detail", "Content:100") .Location("DetailAdmin", "Content:100") diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Extensions/ControllerExtensions.cs b/src/OrchardCore.Modules/OrchardCore.Users/Extensions/ControllerExtensions.cs index 68b99f3828b..c97ae90868c 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Extensions/ControllerExtensions.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Extensions/ControllerExtensions.cs @@ -4,12 +4,11 @@ using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using OrchardCore.DisplayManagement; using OrchardCore.Email; -using OrchardCore.Environment.Shell; using OrchardCore.Modules; using OrchardCore.Mvc.Core.Utilities; -using OrchardCore.Settings; using OrchardCore.Users.Events; using OrchardCore.Users.Models; using OrchardCore.Users.Services; @@ -48,55 +47,45 @@ internal static async Task SendEmailAsync(this Controller controller, stri /// internal static async Task RegisterUser(this Controller controller, RegisterUserForm model, string confirmationEmailSubject, ILogger logger) { - var shellFeaturesManager = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService(); + var registrationEvents = controller.ControllerContext.HttpContext.RequestServices.GetServices(); - var registrationFeatureIsAvailable = (await shellFeaturesManager.GetAvailableFeaturesAsync()) - .Any(feature => feature.Id == UserConstants.Features.UserRegistration); + await registrationEvents.InvokeAsync((e, modelState) => e.RegistrationValidationAsync((key, message) => modelState.AddModelError(key, message)), controller.ModelState, logger); - if (!registrationFeatureIsAvailable) + if (controller.ModelState.IsValid) { - return null; - } - - var settings = await controller.ControllerContext.HttpContext.RequestServices.GetRequiredService().GetSettingsAsync(); - - if (settings.UsersCanRegister != UserRegistrationType.NoRegistration) - { - var registrationEvents = controller.ControllerContext.HttpContext.RequestServices.GetServices(); + var registrationOptions = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService>().Value; - await registrationEvents.InvokeAsync((e, modelState) => e.RegistrationValidationAsync((key, message) => modelState.AddModelError(key, message)), controller.ModelState, logger); + var userService = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService(); - if (controller.ModelState.IsValid) + var user = await userService.CreateUserAsync(new User { - var userService = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService(); + UserName = model.UserName, + Email = model.Email, + EmailConfirmed = !registrationOptions.UsersMustValidateEmail, + IsEnabled = !registrationOptions.UsersAreModerated, + }, model.Password, controller.ModelState.AddModelError) as User; - var user = await userService.CreateUserAsync(new User + if (user != null && controller.ModelState.IsValid) + { + if (registrationOptions.UsersMustValidateEmail && !user.EmailConfirmed) { - 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) + // For more information on how to enable account confirmation and password + // reset please visit http://go.microsoft.com/fwlink/?LinkID=532713 + // Send an email with this link + await controller.SendEmailConfirmationTokenAsync(user, confirmationEmailSubject); + } + else if (!(registrationOptions.UsersAreModerated && !user.IsEnabled)) { - if (settings.UsersMustValidateEmail && !user.EmailConfirmed) - { - // For more information on how to enable account confirmation and password reset please visit http://go.microsoft.com/fwlink/?LinkID=532713 - // Send an email with this link - await controller.SendEmailConfirmationTokenAsync(user, confirmationEmailSubject); - } - 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."); - await registrationEvents.InvokeAsync((e, user) => e.RegisteredAsync(user), user, logger); - - return user; + var signInManager = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService>(); + + await signInManager.SignInAsync(user, isPersistent: false); } + + logger.LogInformation(3, "User created a new account with password."); + + await registrationEvents.InvokeAsync((e, user) => e.RegisteredAsync(user), user, logger); + + return user; } } @@ -106,7 +95,9 @@ internal static async Task RegisterUser(this Controller controller, Regis internal static async Task SendEmailConfirmationTokenAsync(this Controller controller, User user, string subject) { var userManager = controller.ControllerContext.HttpContext.RequestServices.GetRequiredService>(); + var code = await userManager.GenerateEmailConfirmationTokenAsync(user); + var callbackUrl = controller.Url.Action(nameof(EmailConfirmationController.ConfirmEmail), typeof(EmailConfirmationController).ControllerName(), new { diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Handlers/ScriptExternalLoginEventHandler.cs b/src/OrchardCore.Modules/OrchardCore.Users/Handlers/ScriptExternalLoginEventHandler.cs index 6cf8858698f..f8ec263b3c8 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Handlers/ScriptExternalLoginEventHandler.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Handlers/ScriptExternalLoginEventHandler.cs @@ -32,7 +32,7 @@ ILogger logger public async Task GenerateUserName(string provider, IEnumerable claims) { - var registrationSettings = await _siteService.GetSettingsAsync(); + var registrationSettings = await _siteService.GetSettingsAsync(); if (registrationSettings.UseScriptToGenerateUsername) { @@ -58,19 +58,19 @@ public async Task GenerateUserName(string provider, IEnumerable(); + var loginSettings = await _siteService.GetSettingsAsync(); UpdateUserInternal(context, loginSettings); } - public void UpdateUserInternal(UpdateUserContext context, LoginSettings loginSettings) + public void UpdateUserInternal(UpdateUserContext context, ExternalLoginSettings loginSettings) { - if (!loginSettings.UseScriptToSyncRoles) + if (!loginSettings.UseScriptToSyncProperties) { return; } - var script = $"js: function syncRoles(context) {{\n{loginSettings.SyncRolesScript}\n}}\nvar context={JConvert.SerializeObject(context, JOptions.CamelCase)};\nsyncRoles(context);\nreturn context;"; + var script = $"js: function syncRoles(context) {{\n{loginSettings.SyncPropertiesScript}\n}}\nvar context={JConvert.SerializeObject(context, JOptions.CamelCase)};\nsyncRoles(context);\nreturn context;"; dynamic evaluationResult = _scriptingManager.Evaluate(script, null, null, null); context.RolesToAdd.AddRange((evaluationResult.rolesToAdd as object[]).Select(i => i.ToString())); context.RolesToRemove.AddRange((evaluationResult.rolesToRemove as object[]).Select(i => i.ToString())); diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs index ffc43e988bc..8c2f0b0ac63 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Manifest.cs @@ -19,6 +19,18 @@ Category = "Security" )] +[assembly: Feature( + Id = UserConstants.Features.ExternalAuthentication, + Name = "External Authentication", + Description = "Provides a way to allow authentication using an external identity provider.", + EnabledByDependencyOnly = true, + Dependencies = + [ + UserConstants.Features.Users, + ], + Category = "Security" +)] + [assembly: Feature( Id = "OrchardCore.Users.ChangeEmail", Name = "Users Change Email", diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Models/ExternalRegistrationSettings.cs b/src/OrchardCore.Modules/OrchardCore.Users/Models/ExternalRegistrationSettings.cs new file mode 100644 index 00000000000..614bb68e18a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Models/ExternalRegistrationSettings.cs @@ -0,0 +1,16 @@ +namespace OrchardCore.Users.Models; + +public class ExternalRegistrationSettings +{ + public bool DisableNewRegistrations { get; set; } + + public bool NoPassword { get; set; } + + public bool NoUsername { get; set; } + + public bool NoEmail { get; set; } + + public bool UseScriptToGenerateUsername { get; set; } + + public string GenerateUsernameScript { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Models/RegistrationSettings.cs b/src/OrchardCore.Modules/OrchardCore.Users/Models/RegistrationSettings.cs index 38a1e843147..aba0d327859 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Models/RegistrationSettings.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Models/RegistrationSettings.cs @@ -2,13 +2,27 @@ namespace OrchardCore.Users.Models; public class RegistrationSettings { + [Obsolete("This property is no longer used and will be removed in the next major release.")] public UserRegistrationType UsersCanRegister { get; set; } + public bool UsersMustValidateEmail { get; set; } + public bool UsersAreModerated { get; set; } + public bool UseSiteTheme { get; set; } + + [Obsolete("This property is no longer used and will be removed in the next major release. Instead use ExternalAuthenticationSettings.NoPassword")] public bool NoPasswordForExternalUsers { get; set; } + + [Obsolete("This property is no longer used and will be removed in the next major release. Instead use ExternalAuthenticationSettings.NoUsername")] public bool NoUsernameForExternalUsers { get; set; } + + [Obsolete("This property is no longer used and will be removed in the next major release. Instead use ExternalAuthenticationSettings.NoEmail")] public bool NoEmailForExternalUsers { get; set; } + + [Obsolete("This property is no longer used and will be removed in the next major release. Instead use ExternalAuthenticationSettings.UseScriptToGenerateUsername")] public bool UseScriptToGenerateUsername { get; set; } + + [Obsolete("This property is no longer used and will be removed in the next major release. Instead use ExternalAuthenticationSettings.GenerateUsernameScript")] public string GenerateUsernameScript { get; set; } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Models/UserRegistrationType.cs b/src/OrchardCore.Modules/OrchardCore.Users/Models/UserRegistrationType.cs index 5e0596069a9..8788aed6842 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Models/UserRegistrationType.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Models/UserRegistrationType.cs @@ -1,5 +1,6 @@ namespace OrchardCore.Users.Models; +[Obsolete("This type is no longer used and will be removed in the next major release.")] public enum UserRegistrationType { NoRegistration = 0, diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Services/RegistrationOptionsConfigurations.cs b/src/OrchardCore.Modules/OrchardCore.Users/Services/RegistrationOptionsConfigurations.cs new file mode 100644 index 00000000000..9a783a90edd --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Services/RegistrationOptionsConfigurations.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Options; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.Services; + +public sealed class RegistrationOptionsConfigurations : IConfigureOptions +{ + private readonly ISiteService _siteService; + + public RegistrationOptionsConfigurations(ISiteService siteService) + { + _siteService = siteService; + } + + public void Configure(RegistrationOptions options) + { + var settings = _siteService.GetSettingsAsync() + .GetAwaiter() + .GetResult(); + + options.UsersMustValidateEmail = settings.UsersMustValidateEmail; + options.UsersAreModerated = settings.UsersAreModerated; + options.UseSiteTheme = settings.UseSiteTheme; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Services/UsersThemeSelector.cs b/src/OrchardCore.Modules/OrchardCore.Users/Services/UsersThemeSelector.cs index d6dc7dc9444..3bd5835deb5 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Services/UsersThemeSelector.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Services/UsersThemeSelector.cs @@ -41,6 +41,7 @@ public async Task GetThemeAsync() switch (routeValues["controller"]?.ToString()) { case "Account": + case "ExternalAuthentications": useSiteTheme = (await _siteService.GetSettingsAsync()).UseSiteTheme; break; case "TwoFactorAuthentication": diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs index e014e279cde..5c81c3275e5 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs @@ -38,6 +38,7 @@ using OrchardCore.Sms; using OrchardCore.Users.Commands; using OrchardCore.Users.Controllers; +using OrchardCore.Users.DataMigrations; using OrchardCore.Users.Deployment; using OrchardCore.Users.Drivers; using OrchardCore.Users.Handlers; @@ -56,8 +57,8 @@ public sealed class Startup : StartupBase private static readonly string _accountControllerName = typeof(AccountController).ControllerName(); private static readonly string _emailConfirmationControllerName = typeof(EmailConfirmationController).ControllerName(); - private readonly string _tenantName; + private UserOptions _userOptions; public Startup(ShellSettings shellSettings) @@ -65,92 +66,10 @@ public Startup(ShellSettings shellSettings) _tenantName = shellSettings.Name; } - public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) - { - _userOptions ??= serviceProvider.GetRequiredService>().Value; - - routes.MapAreaControllerRoute( - name: "Login", - areaName: UserConstants.Features.Users, - pattern: _userOptions.LoginPath, - defaults: new - { - controller = _accountControllerName, - action = nameof(AccountController.Login), - } - ); - - routes.MapAreaControllerRoute( - name: "ChangePassword", - areaName: UserConstants.Features.Users, - pattern: _userOptions.ChangePasswordUrl, - defaults: new - { - controller = _accountControllerName, - action = nameof(AccountController.ChangePassword), - } - ); - - routes.MapAreaControllerRoute( - name: "ChangePasswordConfirmation", - areaName: UserConstants.Features.Users, - pattern: _userOptions.ChangePasswordConfirmationUrl, - defaults: new - { - controller = _accountControllerName, - action = nameof(AccountController.ChangePasswordConfirmation), - } - ); - - routes.MapAreaControllerRoute( - name: "UsersLogOff", - areaName: UserConstants.Features.Users, - pattern: _userOptions.LogoffPath, - defaults: new - { - controller = _accountControllerName, - action = nameof(AccountController.LogOff), - } - ); - - routes.MapAreaControllerRoute( - name: "ExternalLogins", - areaName: UserConstants.Features.Users, - pattern: _userOptions.ExternalLoginsUrl, - defaults: new - { - controller = _accountControllerName, - action = nameof(AccountController.ExternalLogins), - } - ); - - routes.MapAreaControllerRoute( - name: "ConfirmEmail", - areaName: UserConstants.Features.Users, - pattern: "ConfirmEmail", - defaults: new - { - controller = _emailConfirmationControllerName, - action = nameof(EmailConfirmationController.ConfirmEmail), - } - ); - - routes.MapAreaControllerRoute( - name: "ConfirmEmailSent", - areaName: UserConstants.Features.Users, - pattern: "ConfirmEmailSent", - defaults: new - { - controller = _emailConfirmationControllerName, - action = nameof(EmailConfirmationController.ConfirmEmailSent), - } - ); - - builder.UseAuthorization(); - } - public override void ConfigureServices(IServiceCollection services) { + services.AddDataMigration(); + services.Configure(userOptions => { var configuration = ShellScope.Services.GetRequiredService(); @@ -245,6 +164,112 @@ public override void ConfigureServices(IServiceCollection services) services.AddRecipeExecutionStep(); services.AddScoped, LoginFormDisplayDriver>(); } + + public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + _userOptions ??= serviceProvider.GetRequiredService>().Value; + + routes.MapAreaControllerRoute( + name: "Login", + areaName: UserConstants.Features.Users, + pattern: _userOptions.LoginPath, + defaults: new + { + controller = _accountControllerName, + action = nameof(AccountController.Login), + } + ); + + routes.MapAreaControllerRoute( + name: "ChangePassword", + areaName: UserConstants.Features.Users, + pattern: _userOptions.ChangePasswordUrl, + defaults: new + { + controller = _accountControllerName, + action = nameof(AccountController.ChangePassword), + } + ); + + routes.MapAreaControllerRoute( + name: "ChangePasswordConfirmation", + areaName: UserConstants.Features.Users, + pattern: _userOptions.ChangePasswordConfirmationUrl, + defaults: new + { + controller = _accountControllerName, + action = nameof(AccountController.ChangePasswordConfirmation), + } + ); + + routes.MapAreaControllerRoute( + name: "UsersLogOff", + areaName: UserConstants.Features.Users, + pattern: _userOptions.LogoffPath, + defaults: new + { + controller = _accountControllerName, + action = nameof(AccountController.LogOff), + } + ); + + routes.MapAreaControllerRoute( + name: "ConfirmEmail", + areaName: UserConstants.Features.Users, + pattern: "ConfirmEmail", + defaults: new + { + controller = _emailConfirmationControllerName, + action = nameof(EmailConfirmationController.ConfirmEmail), + } + ); + + routes.MapAreaControllerRoute( + name: "ConfirmEmailSent", + areaName: UserConstants.Features.Users, + pattern: "ConfirmEmailSent", + defaults: new + { + controller = _emailConfirmationControllerName, + action = nameof(EmailConfirmationController.ConfirmEmailSent), + } + ); + + builder.UseAuthorization(); + } +} + +[Feature(UserConstants.Features.ExternalAuthentication)] +public sealed class ExternalAuthenticationStartup : StartupBase +{ + private static readonly string _accountControllerName = typeof(AccountController).ControllerName(); + + private UserOptions _userOptions; + + public override void ConfigureServices(IServiceCollection services) + { + services.AddNavigationProvider(); + services.AddScoped, ExternalAuthenticationUserMenuDisplayDriver>(); + services.AddSiteDisplayDriver(); + services.AddSiteDisplayDriver(); + services.AddTransient, ExternalLoginOptionsConfigurations>(); + } + + public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + _userOptions ??= serviceProvider.GetRequiredService>().Value; + + routes.MapAreaControllerRoute( + name: "ExternalLogins", + areaName: UserConstants.Features.Users, + pattern: _userOptions.ExternalLoginsUrl, + defaults: new + { + controller = _accountControllerName, + action = nameof(ExternalAuthenticationsController.ExternalLogins), + } + ); + } } [RequireFeatures("OrchardCore.Roles")] @@ -390,6 +415,21 @@ public sealed class RegistrationStartup : StartupBase private const string RegistrationPending = nameof(RegistrationController.RegistrationPending); private const string RegistrationControllerName = "Registration"; + public override void ConfigureServices(IServiceCollection services) + { + services.Configure(o => + { + o.MemberAccessStrategy.Register(); + }); + + services.AddSiteDisplayDriver(); + services.AddNavigationProvider(); + + services.AddScoped, RegisterUserLoginFormDisplayDriver>(); + services.AddScoped, RegisterUserFormDisplayDriver>(); + services.AddTransient, RegistrationOptionsConfigurations>(); + } + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) { routes.MapAreaControllerRoute( @@ -425,20 +465,6 @@ public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder ro } ); } - - public override void ConfigureServices(IServiceCollection services) - { - services.Configure(o => - { - o.MemberAccessStrategy.Register(); - }); - - services.AddSiteDisplayDriver(); - services.AddNavigationProvider(); - - services.AddScoped, RegisterUserLoginFormDisplayDriver>(); - services.AddScoped, RegisterUserFormDisplayDriver>(); - } } [Feature(UserConstants.Features.UserRegistration)] @@ -460,6 +486,25 @@ public sealed class ResetPasswordStartup : StartupBase private const string ResetPasswordConfirmationPath = "ResetPasswordConfirmation"; private const string ResetPasswordControllerName = "ResetPassword"; + public override void ConfigureServices(IServiceCollection services) + { + services.AddTransient, PasswordResetIdentityOptionsConfigurations>() + .AddTransient() + .AddOptions(); + + services.Configure(o => + { + o.MemberAccessStrategy.Register(); + }); + + services.AddSiteDisplayDriver(); + services.AddNavigationProvider(); + + services.AddScoped, ResetPasswordFormDisplayDriver>(); + services.AddScoped, ForgotPasswordLoginFormDisplayDriver>(); + services.AddScoped, ForgotPasswordFormDisplayDriver>(); + } + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) { routes.MapAreaControllerRoute( @@ -503,25 +548,6 @@ public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder ro } ); } - - public override void ConfigureServices(IServiceCollection services) - { - services.AddTransient, PasswordResetIdentityOptionsConfigurations>() - .AddTransient() - .AddOptions(); - - services.Configure(o => - { - o.MemberAccessStrategy.Register(); - }); - - services.AddSiteDisplayDriver(); - services.AddNavigationProvider(); - - services.AddScoped, ResetPasswordFormDisplayDriver>(); - services.AddScoped, ForgotPasswordLoginFormDisplayDriver>(); - services.AddScoped, ForgotPasswordFormDisplayDriver>(); - } } [Feature(UserConstants.Features.ResetPassword)] diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/Login.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/Login.cshtml index 070c6d2614f..d50f998d470 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/Login.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/Login.cshtml @@ -6,7 +6,6 @@ @using OrchardCore.Users.Models @inject SignInManager SignInManager -@inject ISiteService SiteService @inject UserManager UserManager @inject IDisplayManager LoginFormDisplayManager @inject IUpdateModelAccessor UpdateModelAccessor @@ -15,7 +14,7 @@ ViewLayout = "Layout__Login"; var loginProviders = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToList(); - var disableLocalLogin = (await SiteService.GetSettingsAsync()).DisableLocalLogin; + var disableLocalLogin = Site.As().DisableLocalLogin; } @@ -41,7 +40,7 @@

@T["Use another service to log in"]


-
+ @foreach (var provider in loginProviders) { diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/ExternalLogins.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalAuthentications/ExternalLogins.cshtml similarity index 100% rename from src/OrchardCore.Modules/OrchardCore.Users/Views/Account/ExternalLogins.cshtml rename to src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalAuthentications/ExternalLogins.cshtml diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/LinkExternalLogin.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalAuthentications/LinkExternalLogin.cshtml similarity index 88% rename from src/OrchardCore.Modules/OrchardCore.Users/Views/Account/LinkExternalLogin.cshtml rename to src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalAuthentications/LinkExternalLogin.cshtml index d31e28d1d31..237a8c678ca 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/LinkExternalLogin.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalAuthentications/LinkExternalLogin.cshtml @@ -4,7 +4,7 @@ ViewLayout = "Layout__Login"; } - +

@T["Link your account."]

@T["You've successfully authenticated with {0}. You already have an account that can be linked with this external login. Enter your local account password and click the Register button to link the accounts and finish logging in.", ViewData["LoginProvider"]] diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/RegisterExternalLogin.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalAuthentications/RegisterExternalLogin.cshtml similarity index 93% rename from src/OrchardCore.Modules/OrchardCore.Users/Views/Account/RegisterExternalLogin.cshtml rename to src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalAuthentications/RegisterExternalLogin.cshtml index 2b52597f6d1..8c0bdede6e1 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/RegisterExternalLogin.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalAuthentications/RegisterExternalLogin.cshtml @@ -5,7 +5,7 @@ ViewLayout = "Layout__Login"; } - +

@T["Create a new account."]

@T["You've successfully authenticated with {0}. Please fill the form below and click the Register button to complete the registration.", ViewData["LoginProvider"]] diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalLoginSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalLoginSettings.Edit.cshtml new file mode 100644 index 00000000000..69747b75dd9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalLoginSettings.Edit.cshtml @@ -0,0 +1,169 @@ +@using OrchardCore.Users.Handlers +@model OrchardCore.Users.Models.ExternalLoginSettings + +

@T["Configuring External Authentication for User Login with Third-Party Providers"]
+ +
+
+ + + + @T["If only one external provider is defined, auto challenge the provider to login. You should also configure registration options"] +
+
+ +
+
+ + + + + @T["If selected, none of the implemented {0}, if any, will be triggered.", nameof(IExternalLoginEventHandler)] + +
+
+
+ +
+ +
+ +
+ + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalRegistrationSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalRegistrationSettings.Edit.cshtml new file mode 100644 index 00000000000..0f05544524c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/ExternalRegistrationSettings.Edit.cshtml @@ -0,0 +1,161 @@ +@using OrchardCore.Users.Models +@model ExternalRegistrationSettings + + + + + + + + + +
@T["Configuring External Authentication for User Registration with Third-Party Providers"]
+ +
+
+ + + + @T["If selected, only users with existing local accounts will be able to authenticate, and new users will not be able to register using external authentication. Users without a local account will not be able to authenticate."] +
+
+ +
+
+ + + + @T["When a new user logs in with an external provider, they are not required to provide a local username. You can customize how it works by providing an IExternalLoginEventHandler or writing a script."] +
+
+ +
+
+ + + + @T["When a new user logs in with an external provider and the email claim is defined, they are not required to provide a local email address."] +
+
+ +
+
+ + + + @T["When a new user logs in with an external provider, they are not required to provide a local password."] +
+
+ +
+
+ + + + @T["If selected, any IExternalLoginEventHandlers defined in modules are not triggered"] +
+
+ +
+
+********************************************************************************************
+* context          : {userName,loginProvider,externalClaims[]}                             *
+* ======================================================================================== *
+* -userName        : String                                                                *
+* -loginProvider   : String                                                                *
+* -externalClaims  : [{subject,issuer,originalIssuer,properties[],type,value,valueType}]   *
+*  -subject        : String                                                                *
+*  -issuer         : String                                                                *
+*  -originalIssuer : String                                                                *
+*  -properties     : [{key,value}]                                                         *
+*   -key           : String                                                                *
+*   -value         : String                                                                *
+*  -type           : String                                                                *
+*  -value          : String                                                                *
+*  -valueType      : String                                                                *
+* ======================================================================================== *
+*    Description                                                                           *
+* ---------------------------------------------------------------------------------------- *
+*    Use the loginProvider and externalClaims properties of the context variable to        *
+*    inspect who authenticated the user and with what claims. If you do not set the        *
+*    context.userName property, a username will be generated.                              *
+*                                                                                          *
+********************************************************************************************
+
+ +
+ +
+ +
+ +
+ +
+ + diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettings.Edit.cshtml index 8b274cfbc9e..912b09c0c18 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettings.Edit.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettings.Edit.cshtml @@ -38,15 +38,6 @@ -
-
- - - - @T["If only one external provider is defined, auto challenge the provider to login. You should also configure registration options"] -
-
-
@@ -55,158 +46,3 @@ @T["When selected, users are not allowed to login with username/password. Make sure there is at least one admin with linked account"]
- -
-
- - - - @T["If selected, any IExternalLoginEventHandlers defined in modules are not triggered"] -
-
-
- -
- -
- -
- - - - - diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/RegistrationSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/RegistrationSettings.Edit.cshtml index 3b4e81786f8..d4a06ff957e 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/RegistrationSettings.Edit.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/RegistrationSettings.Edit.cshtml @@ -9,24 +9,6 @@ -
- - - - -
-
    -
  • @T["when 'No Registration' is selected, no new users can register"]
  • -
  • @T["when 'Allow Registration' is selected, a link is displayed to allow the users to register on the site. If there are external providers, once authenticated the user can register"]
  • -
  • @T["when 'Allow Only External Users' is selected, only users authenticated with external providers can register on the site"]
  • -
-
-
-
@@ -53,159 +35,3 @@ @T["Requires an active site theme."]
- -
@T["External Authentication"] @T["Settings when registering with external authentication providers"]
- -
-
- - - - @T["When a new user logs in with an external provider, they are not required to provide a local username. You can customize how it works by providing an IExternalLoginEventHandler or writing a script."] -
-
-
-
- - - - @T["When a new user logs in with an external provider and the email claim is defined, they are not required to provide a local email address."] -
-
-
-
- - - - @T["When a new user logs in with an external provider, they are not required to provide a local password."] -
-
-
-
- - - - @T["If selected, any IExternalLoginEventHandlers defined in modules are not triggered"] -
-
- -
-
-********************************************************************************************
-* context          : {userName,loginProvider,externalClaims[]}                             *
-* ======================================================================================== *
-* -userName        : String                                                                *
-* -loginProvider   : String                                                                *
-* -externalClaims  : [{subject,issuer,originalIssuer,properties[],type,value,valueType}]   *
-*  -subject        : String                                                                *
-*  -issuer         : String                                                                *
-*  -originalIssuer : String                                                                *
-*  -properties     : [{key,value}]                                                         *
-*   -key           : String                                                                *
-*   -value         : String                                                                *
-*  -type           : String                                                                *
-*  -value          : String                                                                *
-*  -valueType      : String                                                                *
-* ======================================================================================== *
-*    Description                                                                           *
-* ---------------------------------------------------------------------------------------- *
-*    Use the loginProvider and externalClaims properties of the context variable to        *
-*    inspect who authenticated the user and with what claims. If you do not set the        *
-*    context.userName property, a username will be generated.                              *
-*                                                                                          *
-********************************************************************************************
-
- -
- -
- -
- -
- -
- - diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/UserMenuItems-ExternalLogins.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/UserMenuItems-ExternalLogins.cshtml index f52842f9684..3e0a99a3145 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/UserMenuItems-ExternalLogins.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/UserMenuItems-ExternalLogins.cshtml @@ -1,5 +1,5 @@
  • - + @T["External Logins"]
  • diff --git a/src/OrchardCore/OrchardCore.Abstractions/Shell/Extensions/ShellFeaturesManagerExtensions.cs b/src/OrchardCore/OrchardCore.Abstractions/Shell/Extensions/ShellFeaturesManagerExtensions.cs index a1f22180bf5..47479d6404d 100644 --- a/src/OrchardCore/OrchardCore.Abstractions/Shell/Extensions/ShellFeaturesManagerExtensions.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Shell/Extensions/ShellFeaturesManagerExtensions.cs @@ -34,6 +34,35 @@ public static async Task EnableFeaturesAsync(this IShellFeaturesManager shellFea await shellFeaturesManager.EnableFeaturesAsync(featuresToEnable, force: false); } + public static async Task UpdateFeaturesAsync(this IShellFeaturesManager shellFeaturesManager, IEnumerable featureIdsToDisable, IEnumerable featureIdsToEnable) + { + ArgumentNullException.ThrowIfNull(featureIdsToEnable); + ArgumentNullException.ThrowIfNull(featureIdsToDisable); + + var availableFeatures = await shellFeaturesManager.GetAvailableFeaturesAsync(); + + var featuresToDisable = availableFeatures.Where(feature => featureIdsToDisable.Contains(feature.Id)); + var featuresToEnable = availableFeatures.Where(feature => featureIdsToEnable.Contains(feature.Id)); + + await shellFeaturesManager.UpdateFeaturesAsync(featuresToDisable, featuresToEnable, force: false); + } + + public static async Task DisableFeaturesAsync(this IShellFeaturesManager shellFeaturesManager, params string[] featureIds) + { + ArgumentNullException.ThrowIfNull(featureIds); + + if (featureIds.Length == 0) + { + return; + } + + var availableFeatures = await shellFeaturesManager.GetAvailableFeaturesAsync(); + + var featuresToEnable = availableFeatures.Where(feature => featureIds.Contains(feature.Id)); + + await shellFeaturesManager.DisableFeaturesAsync(featuresToEnable, force: false); + } + public static Task> DisableFeaturesAsync(this IShellFeaturesManager shellFeaturesManager, IEnumerable features) { diff --git a/src/OrchardCore/OrchardCore.Users.Core/Models/ExternalLoginOptions.cs b/src/OrchardCore/OrchardCore.Users.Core/Models/ExternalLoginOptions.cs new file mode 100644 index 00000000000..550ca9469aa --- /dev/null +++ b/src/OrchardCore/OrchardCore.Users.Core/Models/ExternalLoginOptions.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Users.Models; + +public class ExternalLoginOptions +{ + public bool UseExternalProviderIfOnlyOneDefined { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Users.Core/Models/ExternalLoginSettings.cs b/src/OrchardCore/OrchardCore.Users.Core/Models/ExternalLoginSettings.cs new file mode 100644 index 00000000000..332fa32a036 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Users.Core/Models/ExternalLoginSettings.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Users.Models; + +public class ExternalLoginSettings +{ + public bool UseExternalProviderIfOnlyOneDefined { get; set; } + + public bool UseScriptToSyncProperties { get; set; } + + public string SyncPropertiesScript { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Users.Core/Models/LoginSettings.cs b/src/OrchardCore/OrchardCore.Users.Core/Models/LoginSettings.cs index 9228fba989f..365f19dbea8 100644 --- a/src/OrchardCore/OrchardCore.Users.Core/Models/LoginSettings.cs +++ b/src/OrchardCore/OrchardCore.Users.Core/Models/LoginSettings.cs @@ -6,12 +6,15 @@ public class LoginSettings { public bool UseSiteTheme { get; set; } + [Obsolete("This property is no longer used and will be removed in the next major release. Instead use ExternalUserLoginSettings.NoPassword")] public bool UseExternalProviderIfOnlyOneDefined { get; set; } public bool DisableLocalLogin { get; set; } + [Obsolete("This property is no longer used and will be removed in the next major release. Instead use ExternalUserRoleLoginSettings.SyncRolesScript")] public bool UseScriptToSyncRoles { get; set; } + [Obsolete("This property is no longer used and will be removed in the next major release. Instead use ExternalUserRoleLoginSettings.SyncRolesScript")] public string SyncRolesScript { get; set; } public bool AllowChangingUsername { get; set; } diff --git a/src/OrchardCore/OrchardCore.Users.Core/Models/RegistrationOptions.cs b/src/OrchardCore/OrchardCore.Users.Core/Models/RegistrationOptions.cs new file mode 100644 index 00000000000..fffeeef6f9f --- /dev/null +++ b/src/OrchardCore/OrchardCore.Users.Core/Models/RegistrationOptions.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Users.Models; + +public class RegistrationOptions +{ + public bool UsersMustValidateEmail { get; set; } + + public bool UsersAreModerated { get; set; } + + public bool UseSiteTheme { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Users.Core/Services/ExternalLoginOptionsConfigurations.cs b/src/OrchardCore/OrchardCore.Users.Core/Services/ExternalLoginOptionsConfigurations.cs new file mode 100644 index 00000000000..f11a5e9292d --- /dev/null +++ b/src/OrchardCore/OrchardCore.Users.Core/Services/ExternalLoginOptionsConfigurations.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Options; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.Services; + +public sealed class ExternalLoginOptionsConfigurations : IConfigureOptions +{ + private readonly ISiteService _siteService; + + public ExternalLoginOptionsConfigurations(ISiteService siteService) + { + _siteService = siteService; + } + + public void Configure(ExternalLoginOptions options) + { + var settings = _siteService.GetSettingsAsync() + .GetAwaiter() + .GetResult(); + + options.UseExternalProviderIfOnlyOneDefined = settings.UseExternalProviderIfOnlyOneDefined; + } +} diff --git a/src/OrchardCore/OrchardCore.Users.Core/UserConstants.cs b/src/OrchardCore/OrchardCore.Users.Core/UserConstants.cs index 596ee932221..1b774450120 100644 --- a/src/OrchardCore/OrchardCore.Users.Core/UserConstants.cs +++ b/src/OrchardCore/OrchardCore.Users.Core/UserConstants.cs @@ -18,6 +18,8 @@ public static class Features public const string UserRegistration = "OrchardCore.Users.Registration"; + public const string ExternalAuthentication = "OrchardCore.Users.ExternalAuthentication"; + public const string ResetPassword = "OrchardCore.Users.ResetPassword"; } } diff --git a/src/OrchardCore/OrchardCore.Users.Core/UserManagerHelper.cs b/src/OrchardCore/OrchardCore.Users.Core/UserManagerHelper.cs new file mode 100644 index 00000000000..de2200d0fc4 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Users.Core/UserManagerHelper.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Settings; +using Microsoft.AspNetCore.Identity; +using OrchardCore.Users.Handlers; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users; + +public static class UserManagerHelper +{ + private static readonly JsonMergeSettings _jsonMergeSettings = new() + { + MergeArrayHandling = MergeArrayHandling.Replace, + MergeNullValueHandling = MergeNullValueHandling.Merge, + }; + + public static async Task UpdateUserPropertiesAsync(UserManager userManager, User user, UpdateUserContext context) + { + await userManager.AddToRolesAsync(user, context.RolesToAdd.Distinct()); + await userManager.RemoveFromRolesAsync(user, context.RolesToRemove.Distinct()); + + var userNeedUpdate = false; + if (context.PropertiesToUpdate != null) + { + var currentProperties = user.Properties.DeepClone(); + user.Properties.Merge(context.PropertiesToUpdate, _jsonMergeSettings); + userNeedUpdate = !JsonNode.DeepEquals(currentProperties, user.Properties); + } + + var currentClaims = user.UserClaims + .Where(x => !string.IsNullOrEmpty(x.ClaimType)) + .DistinctBy(x => new { x.ClaimType, x.ClaimValue }) + .ToList(); + + var claimsChanged = false; + if (context.ClaimsToRemove?.Count > 0) + { + var claimsToRemove = context.ClaimsToRemove.ToHashSet(); + foreach (var item in claimsToRemove) + { + var exists = currentClaims.FirstOrDefault(claim => claim.ClaimType == item.ClaimType && claim.ClaimValue == item.ClaimValue); + if (exists is not null) + { + currentClaims.Remove(exists); + claimsChanged = true; + } + } + } + + if (context.ClaimsToUpdate?.Count > 0) + { + foreach (var item in context.ClaimsToUpdate) + { + var existing = currentClaims.FirstOrDefault(claim => claim.ClaimType == item.ClaimType && claim.ClaimValue == item.ClaimValue); + if (existing is null) + { + currentClaims.Add(item); + claimsChanged = true; + } + } + } + + if (claimsChanged) + { + user.UserClaims = currentClaims; + userNeedUpdate = true; + } + + return userNeedUpdate; + } +} diff --git a/src/docs/reference/modules/Users/README.md b/src/docs/reference/modules/Users/README.md index 23298f9c370..16f301772e6 100644 --- a/src/docs/reference/modules/Users/README.md +++ b/src/docs/reference/modules/Users/README.md @@ -7,7 +7,8 @@ The Users module enables authentication UI and user management. The module contains the following features apart from the base feature: - Users Change Email: Allows users to change their email address. -- Users Registration: Allows external users to sign up to the site and ask to confirm their email. +- Users Registration: Allows new users to sign up to the site and ask to confirm their email. +- External User Authentication: Enables a way to authenticate users using an external identity provider. - Reset Password: Allows users to reset their password. - User Time Zone: Provides a way to set the time zone per user. - Custom User Settings: See [its own documentation page](CustomUserSettings/README.md). diff --git a/src/docs/releases/2.1.0.md b/src/docs/releases/2.1.0.md index fd7b3458ad6..9ddac2b6a91 100644 --- a/src/docs/releases/2.1.0.md +++ b/src/docs/releases/2.1.0.md @@ -4,6 +4,52 @@ Release date: Not yet released ## Change Logs +### External Authentication Feature + +We've introduced a new feature called **External Authentication**. This feature has been separated from the existing **Users** feature to improve dependency management and to provide an option to disable external authentication by default. As a result, the **User Registration** feature no longer needs to be enabled unless you specifically want to allow site registration. + +This feature is only available on-demand and cannot be manually enabled or disabled. It is automatically enabled when a feature requiring external authentication is activated. + +We included a fast-forward migration to enable this feature automatically if previously needed. No action is needed from site administrators. + +The following settings have been relocated to new classes as part of this update: + +- The properties `UseScriptToSyncRoles` and `SyncRolesScript` have been moved from `LoginSettings` to the new `ExternalLoginSettings` class. +- The property `UseExternalProviderIfOnlyOneDefined` has also been moved from `LoginSettings` to `ExternalLoginSettings`. + +In addition, several properties have been moved from `RegistrationSettings` to a new `ExternalRegistrationSettings` class: + +- `NoPasswordForExternalUsers` is now `ExternalRegistrationSettings.NoPassword`. +- `NoUsernameForExternalUsers` is now `ExternalRegistrationSettings.NoUsername`. +- `NoEmailForExternalUsers` is now `ExternalRegistrationSettings.NoEmail`. +- `UseScriptToGenerateUsername` is now `ExternalRegistrationSettings.UseScriptToGenerateUsername`. +- `GenerateUsernameScript` is now `ExternalRegistrationSettings.GenerateUsernameScript`. + +Moreover, a new property, `DisableNewRegistrations`, has been added to `ExternalRegistrationSettings`, allowing you to prevent new external users from registering on the site. This property will be set by default if you are currently using the **NoRegistration** value for the obsolete `UsersCanRegister` property. + +Also, note the following updates in `ExternalLoginSettings`: + +- `UseScriptToSyncRoles` has been renamed to `ExternalLoginSettings.UseScriptToSyncProperties`. +- `SyncRolesScript` has been renamed to `ExternalLoginSettings.SyncPropertiesScript`. + +!!! note + When updating recipes to configure `LoginSettings` or `RegistrationSettings`, ensure that the settings reflect the new class structure. + +### User Registration Feature + +The **User Registration** feature is no longer required if you only want to enable external authentication. + +The following properties of `RegistrationSettings` are now deprecated and will be removed in the next major release: + +- `UsersCanRegister` +- `NoPasswordForExternalUsers` +- `NoUsernameForExternalUsers` +- `NoEmailForExternalUsers` +- `UseScriptToGenerateUsername` +- `GenerateUsernameScript` + +Previously, the `UsersCanRegister` property controlled which types of registration were allowed. With this update, this property is obsolete and will be removed in a future release. To enable site registration now, simply activate the **User Registration** feature. + ### New 'Azure Communication SMS' feature A new feature was added to allow you to send SMS messages using Azure Communication Services (ACS). Simply enable it then navigate to the admin dashboard > `Configurations` >> `Settings` >> `SMS` to configure the provider. For more information you can refer to the [docs](../reference/modules/Sms.Azure/README.md). diff --git a/test/OrchardCore.Tests/OrchardCore.Users/AccountControllerTests.cs b/test/OrchardCore.Tests/OrchardCore.Users/AccountControllerTests.cs index a61ca667837..a9bb72444fe 100644 --- a/test/OrchardCore.Tests/OrchardCore.Users/AccountControllerTests.cs +++ b/test/OrchardCore.Tests/OrchardCore.Users/AccountControllerTests.cs @@ -1,8 +1,6 @@ using System.Text.Json.Nodes; +using OrchardCore.Deployment.Services; 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.Apis.Context; @@ -21,10 +19,7 @@ public class AccountControllerTests public async Task ExternalLoginSignIn_Test() { // Arrange - var context = await GetSiteContextAsync(new RegistrationSettings() - { - UsersCanRegister = UserRegistrationType.AllowRegistration, - }); + var context = await GetSiteContextAsync(new RegistrationSettings(), true, true, true); // Act var model = new RegisterViewModel() @@ -63,10 +58,10 @@ await context.UsingTenantScopeAsync(async scope => var scriptExternalLoginEventHandler = scope.ServiceProvider.GetServices() .FirstOrDefault(x => x.GetType() == typeof(ScriptExternalLoginEventHandler)) as ScriptExternalLoginEventHandler; - var loginSettings = new LoginSettings + var loginSettings = new ExternalLoginSettings { - UseScriptToSyncRoles = true, - SyncRolesScript = """ + UseScriptToSyncProperties = true, + SyncPropertiesScript = """ if(!context.user.userClaims?.find(x=> x.claimType=="lastName" && claimValue=="Zhang")){ context.claimsToUpdate.push({claimType:"lastName", claimValue:"Zhang"}); } @@ -94,7 +89,7 @@ await context.UsingTenantScopeAsync(async scope => }; scriptExternalLoginEventHandler.UpdateUserInternal(context, loginSettings); - if (await AccountController.UpdateUserPropertiesAsync(userManager, user, context)) + if (await UserManagerHelper.UpdateUserPropertiesAsync(userManager, user, context)) { await userManager.UpdateAsync(user); } @@ -130,10 +125,10 @@ await context.UsingTenantScopeAsync(async scope => var scriptExternalLoginEventHandler = scope.ServiceProvider.GetServices() .FirstOrDefault(x => x.GetType() == typeof(ScriptExternalLoginEventHandler)) as ScriptExternalLoginEventHandler; - var loginSettings = new LoginSettings + var loginSettings = new ExternalLoginSettings { - UseScriptToSyncRoles = true, - SyncRolesScript = """ + UseScriptToSyncProperties = true, + SyncPropertiesScript = """ context.claimsToUpdate.push({claimType:"displayName", claimValue:"Sam Zhang"}); context.claimsToUpdate.push({claimType:"firstName", claimValue:"Sam"}); context.claimsToUpdate.push({claimType:"lastName", claimValue:"Zhang"}); @@ -152,7 +147,7 @@ await context.UsingTenantScopeAsync(async scope => }; scriptExternalLoginEventHandler.UpdateUserInternal(updateContext, loginSettings); - if (await AccountController.UpdateUserPropertiesAsync(userManager, user, updateContext)) + if (await UserManagerHelper.UpdateUserPropertiesAsync(userManager, user, updateContext)) { await userManager.UpdateAsync(user); } @@ -172,10 +167,7 @@ await context.UsingTenantScopeAsync(async scope => public async Task Register_WhenAllowed_RegisterUser() { // Arrange - var context = await GetSiteContextAsync(new RegistrationSettings() - { - UsersCanRegister = UserRegistrationType.AllowRegistration, - }); + var context = await GetSiteContextAsync(new RegistrationSettings()); var responseFromGet = await context.Client.GetAsync("Register"); @@ -211,10 +203,7 @@ await context.UsingTenantScopeAsync(async scope => public async Task Register_WhenNotAllowed_ReturnNotFound() { // Arrange - var context = await GetSiteContextAsync(new RegistrationSettings() - { - UsersCanRegister = UserRegistrationType.NoRegistration, - }); + var context = await GetSiteContextAsync(new RegistrationSettings(), false); // Act var response = await context.Client.GetAsync("Register"); @@ -227,10 +216,7 @@ public async Task Register_WhenNotAllowed_ReturnNotFound() public async Task Register_WhenFeatureIsNotEnable_ReturnNotFound() { // Arrange - var context = await GetSiteContextAsync(new RegistrationSettings() - { - UsersCanRegister = UserRegistrationType.AllowRegistration, - }, enableRegistrationFeature: false); + var context = await GetSiteContextAsync(new RegistrationSettings(), enableRegistrationFeature: false); // Act var response = await context.Client.GetAsync("Register"); @@ -243,10 +229,7 @@ public async Task Register_WhenFeatureIsNotEnable_ReturnNotFound() public async Task Register_WhenRequireUniqueEmailIsTrue_PreventRegisteringMultipleUsersWithTheSameEmails() { // Arrange - var context = await GetSiteContextAsync(new RegistrationSettings() - { - UsersCanRegister = UserRegistrationType.AllowRegistration, - }); + var context = await GetSiteContextAsync(new RegistrationSettings()); var responseFromGet = await context.Client.GetAsync("Register"); @@ -290,10 +273,7 @@ public async Task Register_WhenRequireUniqueEmailIsTrue_PreventRegisteringMultip public async Task Register_WhenRequireUniqueEmailIsFalse_AllowRegisteringMultipleUsersWithTheSameEmails() { // Arrange - var context = await GetSiteContextAsync(new RegistrationSettings() - { - UsersCanRegister = UserRegistrationType.AllowRegistration, - }, enableRegistrationFeature: true, requireUniqueEmail: false); + var context = await GetSiteContextAsync(new RegistrationSettings(), enableRegistrationFeature: true, requireUniqueEmail: false); // Register First User var responseFromGet = await context.Client.GetAsync("Register"); @@ -341,7 +321,6 @@ public async Task Register_WhenModeration_RedirectToRegistrationPending() // Arrange var context = await GetSiteContextAsync(new RegistrationSettings() { - UsersCanRegister = UserRegistrationType.AllowRegistration, UsersAreModerated = true, }); @@ -382,7 +361,6 @@ public async Task Register_WhenRequireEmailConfirmation_RedirectToConfirmEmailSe // Arrange var context = await GetSiteContextAsync(new RegistrationSettings() { - UsersCanRegister = UserRegistrationType.AllowRegistration, UsersMustValidateEmail = true, }); @@ -433,7 +411,7 @@ private static async Task CreateRequestMessageAsync(Register return PostRequestHelper.CreateMessageWithCookies("Register", data, response); } - private static async Task GetSiteContextAsync(RegistrationSettings settings, bool enableRegistrationFeature = true, bool requireUniqueEmail = true) + private static async Task GetSiteContextAsync(RegistrationSettings settings, bool enableRegistrationFeature = true, bool requireUniqueEmail = true, bool enableExternalAuthentication = false) { var context = new SiteContext(); @@ -460,16 +438,6 @@ await recipeExecutor.ExecuteAsync( CancellationToken.None); } - if (enableRegistrationFeature) - { - 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(); @@ -479,6 +447,68 @@ await recipeExecutor.ExecuteAsync( await siteService.UpdateSiteSettingsAsync(site); }); + if (enableRegistrationFeature || enableExternalAuthentication) + { + await context.UsingTenantScopeAsync(async scope => + { + var featureIds = new JsonArray(); + + if (enableRegistrationFeature) + { + featureIds.Add(UserConstants.Features.UserRegistration); + } + + if (enableExternalAuthentication) + { + featureIds.Add(UserConstants.Features.ExternalAuthentication); + } + + var tempArchiveName = Path.GetTempFileName() + ".json"; + var tempArchiveFolder = PathExtensions.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + var data = new JsonObject + { + ["steps"] = new JsonArray + { + new JsonObject + { + { "name", "feature" }, + { "enable", featureIds }, + } + }, + }; + + try + { + using (var stream = new FileStream(tempArchiveName, FileMode.Create)) + { + var bytes = Encoding.UTF8.GetBytes(data.ToString()); + + await stream.WriteAsync(bytes); + } + + Directory.CreateDirectory(tempArchiveFolder); + File.Move(tempArchiveName, Path.Combine(tempArchiveFolder, "Recipe.json")); + + var deploymentManager = scope.ServiceProvider.GetRequiredService(); + + await deploymentManager.ImportDeploymentPackageAsync(new PhysicalFileProvider(tempArchiveFolder)); + } + finally + { + if (File.Exists(tempArchiveName)) + { + File.Delete(tempArchiveName); + } + + if (Directory.Exists(tempArchiveFolder)) + { + Directory.Delete(tempArchiveFolder, true); + } + } + }); + } + return context; } }