diff --git a/src/Umbraco.Core/IEmailSender.cs b/src/Umbraco.Core/IEmailSender.cs index f2c5fd744d..748b8e6b0a 100644 --- a/src/Umbraco.Core/IEmailSender.cs +++ b/src/Umbraco.Core/IEmailSender.cs @@ -8,6 +8,7 @@ namespace Umbraco.Core /// public interface IEmailSender { + // TODO: This would be better if MailMessage was our own abstraction! Task SendAsync(MailMessage message); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs b/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs index a7f0a526f0..4faac5ce52 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs @@ -1,19 +1,26 @@ using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; using System; using System.Collections.Generic; +using System.Linq; using System.Net; +using System.Net.Mail; using System.Threading.Tasks; using Umbraco.Core; using Umbraco.Core.BackOffice; using Umbraco.Core.Configuration; +using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Logging; using Umbraco.Core.Mapping; +using Umbraco.Core.Models; using Umbraco.Core.Models.Membership; using Umbraco.Core.Services; using Umbraco.Extensions; using Umbraco.Net; using Umbraco.Web.BackOffice.Filters; +using Umbraco.Web.Common.ActionsResults; using Umbraco.Web.Common.Attributes; using Umbraco.Web.Common.Controllers; using Umbraco.Web.Common.Exceptions; @@ -36,11 +43,16 @@ namespace Umbraco.Web.BackOffice.Controllers private readonly BackOfficeUserManager _userManager; private readonly BackOfficeSignInManager _signInManager; private readonly IUserService _userService; + private readonly ILocalizedTextService _textService; private readonly UmbracoMapper _umbracoMapper; private readonly IGlobalSettings _globalSettings; + private readonly ISecuritySettings _securitySettings; private readonly ILogger _logger; private readonly IIpResolver _ipResolver; private readonly IUserPasswordConfiguration _passwordConfiguration; + private readonly IEmailSender _emailSender; + private readonly Core.Hosting.IHostingEnvironment _hostingEnvironment; + private readonly IRequestAccessor _requestAccessor; // TODO: We need to import the logic from Umbraco.Web.Editors.AuthenticationController // TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here @@ -50,21 +62,31 @@ namespace Umbraco.Web.BackOffice.Controllers BackOfficeUserManager backOfficeUserManager, BackOfficeSignInManager signInManager, IUserService userService, + ILocalizedTextService textService, UmbracoMapper umbracoMapper, IGlobalSettings globalSettings, + ISecuritySettings securitySettings, ILogger logger, IIpResolver ipResolver, - IUserPasswordConfiguration passwordConfiguration) + IUserPasswordConfiguration passwordConfiguration, + IEmailSender emailSender, + Core.Hosting.IHostingEnvironment hostingEnvironment, + IRequestAccessor requestAccessor) { _webSecurity = webSecurity; _userManager = backOfficeUserManager; _signInManager = signInManager; _userService = userService; + _textService = textService; _umbracoMapper = umbracoMapper; _globalSettings = globalSettings; + _securitySettings = securitySettings; _logger = logger; _ipResolver = ipResolver; _passwordConfiguration = passwordConfiguration; + _emailSender = emailSender; + _hostingEnvironment = hostingEnvironment; + _requestAccessor = requestAccessor; } /// @@ -77,6 +99,45 @@ namespace Umbraco.Web.BackOffice.Controllers return _passwordConfiguration.GetConfiguration(userId != _webSecurity.CurrentUser.Id); } + /// + /// Checks if a valid token is specified for an invited user and if so logs the user in and returns the user object + /// + /// + /// + /// + /// + /// This will also update the security stamp for the user so it can only be used once + /// + [ValidateAngularAntiForgeryToken] + public async Task> PostVerifyInvite([FromQuery] int id, [FromQuery] string token) + { + if (string.IsNullOrWhiteSpace(token)) + return NotFound(); + + var decoded = token.FromUrlBase64(); + if (decoded.IsNullOrWhiteSpace()) + return NotFound(); + + var identityUser = await _userManager.FindByIdAsync(id.ToString()); + if (identityUser == null) + return NotFound(); + + var result = await _userManager.ConfirmEmailAsync(identityUser, decoded); + + if (result.Succeeded == false) + { + throw HttpResponseException.CreateNotificationValidationErrorResponse(result.Errors.ToErrorMessage()); + } + + await _signInManager.SignOutAsync(); + + await _signInManager.SignInAsync(identityUser, false); + + var user = _userService.GetUserById(id); + + return _umbracoMapper.Map(user); + } + [HttpGet] public double GetRemainingTimeoutSeconds() { @@ -134,6 +195,34 @@ namespace Umbraco.Web.BackOffice.Controllers return result; } + /// + /// When a user is invited they are not approved but we need to resolve the partially logged on (non approved) + /// user. + /// + /// + /// + /// We cannot user GetCurrentUser since that requires they are approved, this is the same as GetCurrentUser but doesn't require them to be approved + /// + [UmbracoAuthorize(redirectToUmbracoLogin: false, requireApproval: false)] + [SetAngularAntiForgeryTokens] + public ActionResult GetCurrentInvitedUser() + { + var user = _webSecurity.CurrentUser; + + if (user.IsApproved) + { + // if they are approved, than they are no longer invited and we can return an error + return Forbid(); + } + + var result = _umbracoMapper.Map(user); + + // set their remaining seconds + result.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds(); + + return result; + } + /// /// Logs a user in /// @@ -199,6 +288,118 @@ namespace Umbraco.Web.BackOffice.Controllers throw new HttpResponseException(HttpStatusCode.BadRequest); } + /// + /// Processes a password reset request. Looks for a match on the provided email address + /// and if found sends an email with a link to reset it + /// + /// + [SetAngularAntiForgeryTokens] + public async Task PostRequestPasswordReset(RequestPasswordResetModel model) + { + // If this feature is switched off in configuration the UI will be amended to not make the request to reset password available. + // So this is just a server-side secondary check. + if (_securitySettings.AllowPasswordReset == false) + return BadRequest(); + + var identityUser = await _userManager.FindByEmailAsync(model.Email); + if (identityUser != null) + { + var user = _userService.GetByEmail(model.Email); + if (user != null) + { + var code = await _userManager.GeneratePasswordResetTokenAsync(identityUser); + var callbackUrl = ConstructCallbackUrl(identityUser.Id, code); + + var message = _textService.Localize("resetPasswordEmailCopyFormat", + // Ensure the culture of the found user is used for the email! + UmbracoUserExtensions.GetUserCulture(identityUser.Culture, _textService, _globalSettings), + new[] { identityUser.UserName, callbackUrl }); + + var subject = _textService.Localize("login/resetPasswordEmailCopySubject", + // Ensure the culture of the found user is used for the email! + UmbracoUserExtensions.GetUserCulture(identityUser.Culture, _textService, _globalSettings)); + + var mailMessage = new MailMessage() + { + Subject = subject, + Body = message, + IsBodyHtml = true, + To = { user.Email } + }; + + await _emailSender.SendAsync(mailMessage); + + _userManager.RaiseForgotPasswordRequestedEvent(User, user.Id); + } + } + + return Ok(); + } + + /// + /// Processes a set password request. Validates the request and sets a new password. + /// + /// + [SetAngularAntiForgeryTokens] + public async Task PostSetPassword(SetPasswordModel model) + { + var identityUser = await _userManager.FindByIdAsync(model.UserId.ToString()); + + var result = await _userManager.ResetPasswordAsync(identityUser, model.ResetCode, model.Password); + if (result.Succeeded) + { + var lockedOut = await _userManager.IsLockedOutAsync(identityUser); + if (lockedOut) + { + _logger.Info("User {UserId} is currently locked out, unlocking and resetting AccessFailedCount", model.UserId); + + //// var user = await UserManager.FindByIdAsync(model.UserId); + var unlockResult = await _userManager.SetLockoutEndDateAsync(identityUser, DateTimeOffset.Now); + if (unlockResult.Succeeded == false) + { + _logger.Warn("Could not unlock for user {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First().Description); + } + + var resetAccessFailedCountResult = await _userManager.ResetAccessFailedCountAsync(identityUser); + if (resetAccessFailedCountResult.Succeeded == false) + { + _logger.Warn("Could not reset access failed count {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First().Description); + } + } + + // They've successfully set their password, we can now update their user account to be confirmed + // if user was only invited, then they have not been approved + // but a successful forgot password flow (e.g. if their token had expired and they did a forgot password instead of request new invite) + // means we have verified their email + if (!await _userManager.IsEmailConfirmedAsync(identityUser)) + { + await _userManager.ConfirmEmailAsync(identityUser, model.ResetCode); + } + + // invited is not approved, never logged in, invited date present + /* + if (LastLoginDate == default && IsApproved == false && InvitedDate != null) + return UserState.Invited; + */ + if (identityUser != null && !identityUser.IsApproved) + { + var user = _userService.GetByUsername(identityUser.UserName); + // also check InvitedDate and never logged in, otherwise this would allow a disabled user to reactivate their account with a forgot password + if (user.LastLoginDate == default && user.InvitedDate != null) + { + user.IsApproved = true; + user.InvitedDate = null; + _userService.Save(user); + } + } + + _userManager.RaiseForgotPasswordChangedSuccessEvent(User, model.UserId); + return Ok(); + } + + return new ValidationErrorResult(result.Errors.Any() ? result.Errors.First().Description : "Set password failed"); + } + /// /// Logs the current user out /// @@ -231,5 +432,23 @@ namespace Umbraco.Web.BackOffice.Controllers return userDetail; } + + private string ConstructCallbackUrl(int userId, string code) + { + // Get an mvc helper to get the url + var urlHelper = new UrlHelper(ControllerContext); + var action = urlHelper.Action(nameof(BackOfficeController.ValidatePasswordResetCode), ControllerExtensions.GetControllerName(), + new + { + area = _globalSettings.GetUmbracoMvcArea(_hostingEnvironment), + u = userId, + r = code + }); + + // Construct full URL using configured application URL (which will fall back to request) + var applicationUri = _requestAccessor.GetApplicationUrl(); + var callbackUri = new Uri(applicationUri, action); + return callbackUri.ToString(); + } } } diff --git a/src/Umbraco.Web.Common/ActionsResults/ValidationErrorResult.cs b/src/Umbraco.Web.Common/ActionsResults/ValidationErrorResult.cs new file mode 100644 index 0000000000..c279192221 --- /dev/null +++ b/src/Umbraco.Web.Common/ActionsResults/ValidationErrorResult.cs @@ -0,0 +1,22 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; + +namespace Umbraco.Web.Common.ActionsResults +{ + /// + /// Custom result to return a validation error message with a 400 http response and required headers + /// + public class ValidationErrorResult : ObjectResult + { + public ValidationErrorResult(string errorMessage) : base(new { Message = errorMessage }) + { + StatusCode = (int)HttpStatusCode.BadRequest; + } + + public override void OnFormatting(ActionContext context) + { + base.OnFormatting(context); + context.HttpContext.Response.Headers["X-Status-Reason"] = "Validation failed"; + } + } +} diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index fc5d4504bd..e4773a85d5 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -83,48 +83,6 @@ namespace Umbraco.Web.Editors ?? (_signInManager = TryGetOwinContext().Result.GetBackOfficeSignInManager()); - - /// - /// Checks if a valid token is specified for an invited user and if so logs the user in and returns the user object - /// - /// - /// - /// - /// - /// This will also update the security stamp for the user so it can only be used once - /// - [ValidateAngularAntiForgeryToken] - public async Task PostVerifyInvite([FromUri]int id, [FromUri]string token) - { - if (string.IsNullOrWhiteSpace(token)) - throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); - - var decoded = token.FromUrlBase64(); - if (decoded.IsNullOrWhiteSpace()) - throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); - - var identityUser = await UserManager.FindByIdAsync(id.ToString()); - if (identityUser == null) - throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); - - var result = await UserManager.ConfirmEmailAsync(identityUser, decoded); - - if (result.Succeeded == false) - { - throw new HttpResponseException(Request.CreateNotificationValidationErrorResponse(result.Errors.ToErrorMessage())); - } - - Request.TryGetOwinContext().Result.Authentication.SignOut( - Core.Constants.Security.BackOfficeAuthenticationType, - Core.Constants.Security.BackOfficeExternalAuthenticationType); - - await SignInManager.SignInAsync(identityUser, false, false); - - var user = Services.UserService.GetUserById(id); - - return Mapper.Map(user); - } - [WebApi.UmbracoAuthorize] [ValidateAngularAntiForgeryToken] public async Task PostUnLinkLogin(UnLinkLoginModel unlinkLoginModel) @@ -150,37 +108,6 @@ namespace Umbraco.Web.Editors } - /// - /// When a user is invited they are not approved but we need to resolve the partially logged on (non approved) - /// user. - /// - /// - /// - /// We cannot user GetCurrentUser since that requires they are approved, this is the same as GetCurrentUser but doesn't require them to be approved - /// - [WebApi.UmbracoAuthorize(requireApproval: false)] - [SetAngularAntiForgeryTokens] - public UserDetail GetCurrentInvitedUser() - { - var user = Security.CurrentUser; - - if (user.IsApproved) - { - // if they are approved, than they are no longer invited and we can return an error - throw new HttpResponseException(Request.CreateUserNoAccessResponse()); - } - - var result = Mapper.Map(user); - var httpContextAttempt = TryGetHttpContext(); - if (httpContextAttempt.Success) - { - // set their remaining seconds - result.SecondsUntilTimeout = httpContextAttempt.Result.GetRemainingAuthSeconds(); - } - - return result; - } - // TODO: This should be on the CurrentUserController? [WebApi.UmbracoAuthorize] [ValidateAngularAntiForgeryToken] @@ -190,56 +117,6 @@ namespace Umbraco.Web.Editors return identityUser.Logins.ToDictionary(x => x.LoginProvider, x => x.ProviderKey); } - - /// - /// Processes a password reset request. Looks for a match on the provided email address - /// and if found sends an email with a link to reset it - /// - /// - [SetAngularAntiForgeryTokens] - public async Task PostRequestPasswordReset(RequestPasswordResetModel model) - { - // If this feature is switched off in configuration the UI will be amended to not make the request to reset password available. - // So this is just a server-side secondary check. - if (_securitySettings.AllowPasswordReset == false) - { - throw new HttpResponseException(HttpStatusCode.BadRequest); - } - var identityUser = await UserManager.FindByEmailAsync(model.Email); - if (identityUser != null) - { - var user = Services.UserService.GetByEmail(model.Email); - if (user != null) - { - var code = await UserManager.GeneratePasswordResetTokenAsync(identityUser); - var callbackUrl = ConstructCallbackUrl(identityUser.Id, code); - - var message = Services.TextService.Localize("resetPasswordEmailCopyFormat", - // Ensure the culture of the found user is used for the email! - UmbracoUserExtensions.GetUserCulture(identityUser.Culture, Services.TextService, GlobalSettings), - new[] { identityUser.UserName, callbackUrl }); - - var subject = Services.TextService.Localize("login/resetPasswordEmailCopySubject", - // Ensure the culture of the found user is used for the email! - UmbracoUserExtensions.GetUserCulture(identityUser.Culture, Services.TextService, GlobalSettings)); - - var mailMessage = new MailMessage() - { - Subject = subject, - Body = message, - IsBodyHtml = true, - To = { user.Email} - }; - - await _emailSender.SendAsync(mailMessage); - - UserManager.RaiseForgotPasswordRequestedEvent(User, user.Id); - } - } - - return Request.CreateResponse(HttpStatusCode.OK); - } - /// /// Used to retrieve the 2FA providers for code submission /// @@ -314,109 +191,12 @@ namespace Umbraco.Web.Editors return Request.CreateValidationErrorResponse("Invalid code"); } - /// - /// Processes a set password request. Validates the request and sets a new password. - /// - /// - [SetAngularAntiForgeryTokens] - public async Task PostSetPassword(SetPasswordModel model) - { - var identityUser = await UserManager.FindByIdAsync(model.UserId.ToString()); - - var result = await UserManager.ResetPasswordAsync(identityUser, model.ResetCode, model.Password); - if (result.Succeeded) - { - var lockedOut = await UserManager.IsLockedOutAsync(identityUser); - if (lockedOut) - { - Logger.Info("User {UserId} is currently locked out, unlocking and resetting AccessFailedCount", model.UserId); - - //// var user = await UserManager.FindByIdAsync(model.UserId); - var unlockResult = await UserManager.SetLockoutEndDateAsync(identityUser, DateTimeOffset.Now); - if (unlockResult.Succeeded == false) - { - Logger.Warn("Could not unlock for user {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First().Description); - } - - var resetAccessFailedCountResult = await UserManager.ResetAccessFailedCountAsync(identityUser); - if (resetAccessFailedCountResult.Succeeded == false) - { - Logger.Warn("Could not reset access failed count {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First().Description); - } - } - - // They've successfully set their password, we can now update their user account to be confirmed - // if user was only invited, then they have not been approved - // but a successful forgot password flow (e.g. if their token had expired and they did a forgot password instead of request new invite) - // means we have verified their email - if (!await UserManager.IsEmailConfirmedAsync(identityUser)) - { - await UserManager.ConfirmEmailAsync(identityUser, model.ResetCode); - } - - // invited is not approved, never logged in, invited date present - /* - if (LastLoginDate == default && IsApproved == false && InvitedDate != null) - return UserState.Invited; - */ - if (identityUser != null && !identityUser.IsApproved) - { - var user = Services.UserService.GetByUsername(identityUser.UserName); - // also check InvitedDate and never logged in, otherwise this would allow a disabled user to reactivate their account with a forgot password - if (user.LastLoginDate == default && user.InvitedDate != null) - { - user.IsApproved = true; - user.InvitedDate = null; - Services.UserService.Save(user); - } - } - - UserManager.RaiseForgotPasswordChangedSuccessEvent(User, model.UserId); - return Request.CreateResponse(HttpStatusCode.OK); - } - return Request.CreateValidationErrorResponse( - result.Errors.Any() ? result.Errors.First().Description : "Set password failed"); - } - - - - // NOTE: This has been migrated to netcore, but in netcore we don't explicitly set the principal in this method, that's done in ConfigureUmbracoBackOfficeCookieOptions so don't worry about that private HttpResponseMessage SetPrincipalAndReturnUserDetail(IUser user, IPrincipal principal) { throw new NotImplementedException(); } - private string ConstructCallbackUrl(int userId, string code) - { - // Get an mvc helper to get the url - var http = EnsureHttpContext(); - var urlHelper = new UrlHelper(http.Request.RequestContext); - var action = urlHelper.Action("ValidatePasswordResetCode", "BackOffice", - new - { - area = GlobalSettings.GetUmbracoMvcArea(_hostingEnvironment), - u = userId, - r = code - }); - - // Construct full URL using configured application URL (which will fall back to request) - var applicationUri = _requestAccessor.GetApplicationUrl(); - var callbackUri = new Uri(applicationUri, action); - return callbackUri.ToString(); - } - - - private HttpContextBase EnsureHttpContext() - { - var attempt = this.TryGetHttpContext(); - if (attempt.Success == false) - throw new InvalidOperationException("This method requires that an HttpContext be active"); - return attempt.Result; - } - - - private void AddModelErrors(IdentityResult result, string prefix = "") { foreach (var error in result.Errors)