diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/ISecuritySection.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/ISecuritySection.cs index c3a1df301d..c44c0cf0df 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/ISecuritySection.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/ISecuritySection.cs @@ -6,6 +6,8 @@ bool HideDisabledUsersInBackoffice { get; } + bool AllowPasswordReset { get; } + string AuthCookieName { get; } string AuthCookieDomain { get; } diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/SecurityElement.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/SecurityElement.cs index 34642c8c90..f280b3e20c 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/SecurityElement.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/SecurityElement.cs @@ -28,6 +28,18 @@ namespace Umbraco.Core.Configuration.UmbracoSettings } } + [ConfigurationProperty("allowPasswordReset")] + internal InnerTextConfigurationElement AllowPasswordReset + { + get + { + return new OptionalInnerTextConfigurationElement( + (InnerTextConfigurationElement)this["allowPasswordReset"], + //set the default + true); + } + } + [ConfigurationProperty("authCookieName")] internal InnerTextConfigurationElement AuthCookieName { @@ -62,6 +74,11 @@ namespace Umbraco.Core.Configuration.UmbracoSettings get { return HideDisabledUsersInBackoffice; } } + bool ISecuritySection.AllowPasswordReset + { + get { return AllowPasswordReset; } + } + string ISecuritySection.AuthCookieName { get { return AuthCookieName; } diff --git a/src/Umbraco.Core/Security/BackOfficeUserManager.cs b/src/Umbraco.Core/Security/BackOfficeUserManager.cs index dfe59e1783..ba7e615771 100644 --- a/src/Umbraco.Core/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Core/Security/BackOfficeUserManager.cs @@ -132,7 +132,7 @@ namespace Umbraco.Core.Security // BodyFormat = "Your security code is: {0}" //}); - //manager.EmailService = new EmailService(); + manager.EmailService = new EmailService(); //manager.SmsService = new SmsService(); } diff --git a/src/Umbraco.Core/Security/EmailService.cs b/src/Umbraco.Core/Security/EmailService.cs new file mode 100644 index 0000000000..0a4ca95ceb --- /dev/null +++ b/src/Umbraco.Core/Security/EmailService.cs @@ -0,0 +1,23 @@ +using System.Net.Mail; +using System.Threading.Tasks; +using Microsoft.AspNet.Identity; + +namespace Umbraco.Core.Security +{ + public class EmailService : IIdentityMessageService + { + public async Task SendAsync(IdentityMessage message) + { + using (var client = new SmtpClient()) + using (var mailMessage = new MailMessage()) + { + mailMessage.Body = message.Body; + mailMessage.To.Add(message.Destination); + mailMessage.Subject = message.Subject; + mailMessage.IsBodyHtml = true; + + await client.SendMailAsync(mailMessage); + } + } + } +} diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 01774dc7a2..ce34b6ef8f 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -490,6 +490,7 @@ + diff --git a/src/Umbraco.Tests/Configurations/UmbracoSettings/SecurityElementTests.cs b/src/Umbraco.Tests/Configurations/UmbracoSettings/SecurityElementTests.cs index 8fbf4a1523..58a9a438a2 100644 --- a/src/Umbraco.Tests/Configurations/UmbracoSettings/SecurityElementTests.cs +++ b/src/Umbraco.Tests/Configurations/UmbracoSettings/SecurityElementTests.cs @@ -11,16 +11,25 @@ namespace Umbraco.Tests.Configurations.UmbracoSettings { Assert.IsTrue(SettingsSection.Security.KeepUserLoggedIn == true); } + [Test] public void HideDisabledUsersInBackoffice() { Assert.IsTrue(SettingsSection.Security.HideDisabledUsersInBackoffice == false); } + + [Test] + public void AllowPasswordReset() + { + Assert.IsTrue(SettingsSection.Security.AllowPasswordReset == true); + } + [Test] public void AuthCookieDomain() { Assert.IsTrue(SettingsSection.Security.AuthCookieDomain == null); } + [Test] public void AuthCookieName() { diff --git a/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config b/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config index 80f61371c2..80eaee77d3 100644 --- a/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config +++ b/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config @@ -110,7 +110,10 @@ false - + + + true + diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js index f32602bda6..b170df3c97 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js @@ -51,7 +51,159 @@ function authResource($q, $http, umbRequestHelper, angularHelper) { }), 'Login failed for user ' + username); }, + + /** + * @ngdoc method + * @name umbraco.resources.authResource#performRequestPasswordReset + * @methodOf umbraco.resources.authResource + * + * @description + * Checks to see if the provided email address is a valid user account and sends a link + * to allow them to reset their password + * + * ##usage + *
+         * authResource.performRequestPasswordReset(email)
+         *    .then(function(data) {
+         *        //Do stuff for password reset request...
+         *    });
+         * 
+ * @param {string} email Email address of backoffice user + * @returns {Promise} resourcePromise object + * + */ + performRequestPasswordReset: function (email) { + + if (!email) { + return angularHelper.rejectedPromise({ + errorMsg: 'Email address cannot be empty' + }); + } + + var emailRegex = /\S+@\S+\.\S+/; + if (!emailRegex.test(email)) { + return angularHelper.rejectedPromise({ + errorMsg: 'Email address is not valid' + }); + } + + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "authenticationApiBaseUrl", + "PostRequestPasswordReset"), { + email: email + }), + 'Request password reset failed for email ' + email); + }, + /** + * @ngdoc method + * @name umbraco.resources.authResource#performValidatePasswordResetCode + * @methodOf umbraco.resources.authResource + * + * @description + * Checks to see if the provided password reset code is valid + * + * ##usage + *
+         * authResource.performValidatePasswordResetCode(resetCode)
+         *    .then(function(data) {
+         *        //Allow reset of password
+         *    });
+         * 
+ * @param {integer} userId User Id + * @param {string} resetCode Password reset code + * @returns {Promise} resourcePromise object + * + */ + performValidatePasswordResetCode: function (userId, resetCode) { + + if (!userId) { + return angularHelper.rejectedPromise({ + errorMsg: 'User Id cannot be empty' + }); + } + + if (!resetCode) { + return angularHelper.rejectedPromise({ + errorMsg: 'Reset code cannot be empty' + }); + } + + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "authenticationApiBaseUrl", + "PostValidatePasswordResetCode"), + { + userId: userId, + resetCode: resetCode + }), + 'Password reset code validation failed for userId + ' + userId + ', code' + resetCode); + }, + + /** + * @ngdoc method + * @name umbraco.resources.authResource#performSetPassword + * @methodOf umbraco.resources.authResource + * + * @description + * Checks to see if the provided password reset code is valid and sets the user's password + * + * ##usage + *
+         * authResource.performSetPassword(userId, password, confirmPassword, resetCode)
+         *    .then(function(data) {
+         *        //Password set
+         *    });
+         * 
+ * @param {integer} userId User Id + * @param {string} password New password + * @param {string} confirmPassword Confirmation of new password + * @param {string} resetCode Password reset code + * @returns {Promise} resourcePromise object + * + */ + performSetPassword: function (userId, password, confirmPassword, resetCode) { + + if (!userId) { + return angularHelper.rejectedPromise({ + errorMsg: 'User Id cannot be empty' + }); + } + + if (!password) { + return angularHelper.rejectedPromise({ + errorMsg: 'Password cannot be empty' + }); + } + + if (password !== confirmPassword) { + return angularHelper.rejectedPromise({ + errorMsg: 'Password and confirmation do not match' + }); + } + + if (!resetCode) { + return angularHelper.rejectedPromise({ + errorMsg: 'Reset code cannot be empty' + }); + } + + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "authenticationApiBaseUrl", + "PostSetPassword"), + { + userId: userId, + password: password, + resetCode: resetCode + }), + 'Password reset code validation failed for userId + ' + userId); + }, + unlinkLogin: function (loginProvider, providerKey) { if (!loginProvider || !providerKey) { return angularHelper.rejectedPromise({ diff --git a/src/Umbraco.Web.UI.Client/src/common/services/user.service.js b/src/Umbraco.Web.UI.Client/src/common/services/user.service.js index 3fb291619d..27f0165f4d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/user.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/user.service.js @@ -212,6 +212,38 @@ angular.module('umbraco.services') }); }, + /** Returns a promise, sends a request to the server to send a password reset email if the email is recognised */ + requestPasswordReset: function (email) { + + return authResource.performRequestPasswordReset(email) + .then(function (data) { + // Note that we don't actually confirm if the email address was matched or not, to avoid + // allowing an attacker to determine which email addresses are valid. + var result = { success: true }; + return result; + }); + }, + + /** Returns a promise, sends a request to the server to validate a password reset code */ + validatePasswordResetCode: function (userId, resetCode) { + + return authResource.performValidatePasswordResetCode(userId, resetCode) + .then(function (data) { + var result = { success: data }; + return result; + }); + }, + + /** Returns a promise, sends a request to the server to validate a password reset code */ + setPassword: function (userId, password, confirmPassword, resetCode) { + + return authResource.performSetPassword(userId, password, confirmPassword, resetCode) + .then(function (data) { + var result = { success: data }; + return result; + }); + }, + /** Logs the user out */ logout: function () { diff --git a/src/Umbraco.Web.UI.Client/src/less/pages/login.less b/src/Umbraco.Web.UI.Client/src/less/pages/login.less index 081a194b7e..ff7fbc243a 100644 --- a/src/Umbraco.Web.UI.Client/src/less/pages/login.less +++ b/src/Umbraco.Web.UI.Client/src/less/pages/login.less @@ -66,7 +66,7 @@ font-weight: normal; } -.login-overlay .alert.alert-error { +.login-overlay .alert { display: inline-block; padding-right: 6px; padding-left: 6px; @@ -74,6 +74,13 @@ text-align: center; } +.login-overlay .switch-view { + margin-top: 10px; + a { + color: #fff; + } +} + @media (max-width: 565px) { // Remove padding on login-form on smaller devices .login-overlay .form { diff --git a/src/Umbraco.Web.UI.Client/src/routes.js b/src/Umbraco.Web.UI.Client/src/routes.js index 5d641fcb6c..69e6779a15 100644 --- a/src/Umbraco.Web.UI.Client/src/routes.js +++ b/src/Umbraco.Web.UI.Client/src/routes.js @@ -93,10 +93,10 @@ app.config(function ($routeProvider) { //ensure auth is *not* required so it will redirect to / resolve: canRoute(false) }) - .when('/logout', { + .when('/logout', { redirectTo: '/login/false', - resolve: doLogout() - }) + resolve: doLogout() + }) .when('/:section', { templateUrl: function (rp) { if (rp.section.toLowerCase() === "default" || rp.section.toLowerCase() === "umbraco" || rp.section === "") diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js index 325af02bbc..d76d66a7c4 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js @@ -1,22 +1,38 @@ angular.module("umbraco").controller("Umbraco.Dialogs.LoginController", - function ($scope, $cookies, localizationService, userService, externalLoginInfo) { + function ($scope, $cookies, localizationService, userService, externalLoginInfo, $timeout, $location) { + + var setFieldFocus = function(form, field) { + $timeout(function() { + $("form[name='" + form + "'] input[name='" + field + "']").focus(); + }); + } + + $scope.allowPasswordReset = Umbraco.Sys.ServerVariables.umbracoSettings.allowPasswordReset; + + $scope.showLogin = function () { + $scope.errorMsg = ""; + $scope.view = "login"; + setFieldFocus("loginForm", "username"); + } + + $scope.showRequestPasswordReset = function () { + $scope.errorMsg = ""; + $scope.view = "request-password-reset"; + $scope.showEmailResetConfirmation = false; + setFieldFocus("requestPasswordResetForm", "email"); + } + + $scope.showSetPassword = function () { + $scope.view = "set-password"; + setFieldFocus("setPasswordForm", "password"); + } - /** - * @ngdoc function - * @name signin - * @methodOf MainController - * @function - * - * @description - * signs the user in - */ var d = new Date(); var konamiGreetings = new Array("Suze Sunday", "Malibu Monday", "Tequila Tuesday", "Whiskey Wednesday", "Negroni Day", "Fernet Friday", "Sancerre Saturday"); var konamiMode = $cookies.konamiLogin; - //var weekday = new Array("Super Sunday", "Manic Monday", "Tremendous Tuesday", "Wonderful Wednesday", "Thunder Thursday", "Friendly Friday", "Shiny Saturday"); if (konamiMode == "1") { $scope.greeting = "Happy " + konamiGreetings[d.getDay()]; } else { @@ -43,18 +59,32 @@ } } + // Set initial view - either set password if reset code provided in querystring + // otherwise login form + var userId = $location.search().userId; + var resetCode = $location.search().resetCode; + if (userId && resetCode) { + userService.validatePasswordResetCode(userId, resetCode) + .then(function () { + $scope.showSetPassword(); + }, function () { + $scope.view = "password-reset-code-expired"; + }); + } else { + $scope.showLogin(); + } + $scope.loginSubmit = function (login, password) { //if the login and password are not empty we need to automatically // validate them - this is because if there are validation errors on the server // then the user has to change both username & password to resubmit which isn't ideal, - // so if they're not empty , we'l just make sure to set them to valid. + // so if they're not empty, we'll just make sure to set them to valid. if (login && password && login.length > 0 && password.length > 0) { $scope.loginForm.username.$setValidity('auth', true); $scope.loginForm.password.$setValidity('auth', true); } - if ($scope.loginForm.$invalid) { return; } @@ -84,4 +114,63 @@ } }); }; + + $scope.requestPasswordResetSubmit = function (email) { + + $scope.errorMsg = ""; + $scope.showEmailResetConfirmation = false; + + if ($scope.requestPasswordResetForm.$invalid) { + return; + } + + userService.requestPasswordReset(email) + .then(function () { + $scope.showEmailResetConfirmation = true; + }, function (reason) { + $scope.errorMsg = reason.errorMsg; + $scope.requestPasswordResetForm.email.$setValidity("auth", false); + }); + + $scope.requestPasswordResetForm.email.$viewChangeListeners.push(function () { + if ($scope.requestPasswordResetForm.email.$invalid) { + $scope.requestPasswordResetForm.email.$setValidity('auth', true); + } + }); + }; + + $scope.setPasswordSubmit = function (password, confirmPassword) { + + $scope.showSetPasswordConfirmation = false; + + if (password && confirmPassword && password.length > 0 && confirmPassword.length > 0) { + $scope.setPasswordForm.password.$setValidity('auth', true); + $scope.setPasswordForm.confirmPassword.$setValidity('auth', true); + } + + if ($scope.setPasswordForm.$invalid) { + return; + } + + userService.setPassword(userId, password, confirmPassword, resetCode) + .then(function () { + $scope.showSetPasswordConfirmation = true; + }, function (reason) { + $scope.errorMsg = reason.errorMsg; + $scope.setPasswordForm.password.$setValidity("auth", false); + $scope.setPasswordForm.confirmPassword.$setValidity("auth", false); + }); + + $scope.setPasswordForm.password.$viewChangeListeners.push(function () { + if ($scope.setPasswordForm.password.$invalid) { + $scope.setPasswordForm.password.$setValidity('auth', true); + } + }); + $scope.setPasswordForm.confirmPassword.$viewChangeListeners.push(function () { + if ($scope.setPasswordForm.confirmPassword.$invalid) { + $scope.setPasswordForm.confirmPassword.$setValidity('auth', true); + } + }); + } + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html index 3ca9bcda10..992b98942e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html @@ -2,54 +2,130 @@

{{greeting}}

-

- Log in below. - Log in below -

- -
- -
- {{error}} + +
+ +

+ Log in below. + Log in below +

+ +
+ +
+ {{error}} +
+ +
+ +
+ + + +
+
+ +
+
+
Or
+
+
-
+ +
+ +
-
+
+ +
- + +
+
{{errorMsg}}
+
+ + - -
-
Or
-
-
-
-
- -
+
+

+ Please enter your email address. If your account is located an email will be sent to you containing a link from which you can reset your password. +

-
- -
+ +
+ +
- + -
-
{{errorMsg}}
+
+
{{errorMsg}}
+
+ +
+
+ If your email address has been matched an email with password reset instructions has been sent. +
+
+ + + +
+ +
+

+ Please provide a new password. +

+ +
+ +
+ +
+ +
+ +
+ + + +
+
{{errorMsg}}
+
+ +

+ Your new password has been set and you may now use it to log in. +

+ + +
+
+ +
+
+ The link you have clicked on is invalid or has expired.
- - + +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI/config/umbracoSettings.config b/src/Umbraco.Web.UI/config/umbracoSettings.config index 118b992ae5..b21894e07a 100644 --- a/src/Umbraco.Web.UI/config/umbracoSettings.config +++ b/src/Umbraco.Web.UI/config/umbracoSettings.config @@ -112,6 +112,9 @@ false + + true + diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml index b810183d42..95f68b1219 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml @@ -290,6 +290,7 @@ Enter your username Enter your password + Confirm your password Name the %0%... Enter a name... Label... @@ -297,6 +298,7 @@ Type to search... Type to filter... Type to add tags (press enter after each tag)... + Enter your email Allow at root @@ -660,6 +662,16 @@ To manage your website, simply open the Umbraco back office and start adding con Log in below Session timed out © 2001 - %0%
Umbraco.com

]]>
+ Forgotten password? + Please enter your email address. If your account is located an email will be sent to you containing a link from which you can reset your password. + If your email address has been matched an email with password reset instructions has been sent. + Return to login form + Please provide a new password. + Your new password has been set and you may now use it to log in. + The link you have clicked on is invalid or has expired. + Umbraco: Reset Password + Your username to login to the Umbraco back-office is: {0} + here. ]]> Dashboard diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml index 402902f48c..24380a5680 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml @@ -291,6 +291,7 @@ Enter your username Enter your password + Confirm your password Name the %0%... Enter a name... Label... @@ -298,6 +299,7 @@ Type to search... Type to filter... Type to add tags (press enter after each tag)... + Enter your email @@ -658,6 +660,16 @@ To manage your website, simply open the Umbraco back office and start adding con Log in below Session timed out © 2001 - %0%
Umbraco.com

]]>
+ Forgotten password? + Please enter your email address. If your account is located an email will be sent to you containing a link from which you can reset your password. + If your email address has been matched an email with password reset instructions has been sent. + Return to login form + Please provide a new password. + Your new password has been set and you may now use it to log in. + The link you have clicked on is invalid or has expired. + Umbraco: Reset Password + Your username to login to the Umbraco back-office is: {0} + here. ]]> Dashboard diff --git a/src/Umbraco.Web.UI/web.Template.config b/src/Umbraco.Web.UI/web.Template.config index 7f8752fff7..dd66460408 100644 --- a/src/Umbraco.Web.UI/web.Template.config +++ b/src/Umbraco.Web.UI/web.Template.config @@ -74,7 +74,7 @@ - + diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index ca6a69e238..09027c58f4 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -3,41 +3,29 @@ using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; -using System.Security.Claims; -using System.ServiceModel.Channels; -using System.Text; using System.Threading.Tasks; using System.Web; -using System.Web.Helpers; using System.Web.Http; -using System.Web.Http.Controllers; -using System.Web.Security; using AutoMapper; using Microsoft.AspNet.Identity; +using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin; -using Microsoft.Owin.Security; using Umbraco.Core; using Umbraco.Core.Configuration; -using Umbraco.Core.Models.Membership; +using Umbraco.Core.Logging; +using Umbraco.Core.Security; +using Umbraco.Core.Services; using Umbraco.Web.Models; using Umbraco.Web.Models.ContentEditing; -using Umbraco.Web.Models.Mapping; using Umbraco.Web.Mvc; -using Umbraco.Core.Security; using Umbraco.Web.Security; +using Umbraco.Web.Security.Identity; using Umbraco.Web.WebApi; using Umbraco.Web.WebApi.Filters; -using umbraco.providers; -using Microsoft.AspNet.Identity.Owin; -using Umbraco.Core.Logging; -using Newtonsoft.Json.Linq; -using Umbraco.Core.Models.Identity; -using Umbraco.Web.Security.Identity; using IUser = Umbraco.Core.Models.Membership.IUser; namespace Umbraco.Web.Editors { - /// /// The API controller used for editing content /// @@ -163,9 +151,7 @@ namespace Umbraco.Web.Editors [SetAngularAntiForgeryTokens] public async Task PostLogin(LoginModel loginModel) { - var http = this.TryGetHttpContext(); - if (http.Success == false) - throw new InvalidOperationException("This method requires that an HttpContext be active"); + var http = EnsureHttpContext(); var result = await SignInManager.PasswordSignInAsync( loginModel.Username, loginModel.Password, isPersistent: true, shouldLockout: true); @@ -231,6 +217,106 @@ namespace Umbraco.Web.Editors } } + /// + /// 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 (UmbracoConfig.For.UmbracoSettings().Security.AllowPasswordReset == false) + { + throw new HttpResponseException(HttpStatusCode.BadRequest); + } + + var http = EnsureHttpContext(); + + var identityUser = await SignInManager.UserManager.FindByEmailAsync(model.Email); + if (identityUser != null) + { + var user = Services.UserService.GetByEmail(model.Email); + if (user != null && user.IsLockedOut == false) + { + var code = await UserManager.GeneratePasswordResetTokenAsync(identityUser.Id); + var callbackUrl = ConstuctCallbackUrl(http.Request.Url, identityUser.Id, code); + var message = ConstructPasswordResetEmailMessage(user, callbackUrl); + await UserManager.SendEmailAsync(identityUser.Id, + Services.TextService.Localize("login/resetPasswordEmailCopySubject"), + message); + } + } + + return Request.CreateResponse(HttpStatusCode.OK); + } + + private static string ConstuctCallbackUrl(Uri url, int userId, string code) + { + return string.Format("{0}://{1}/umbraco/#/login?userId={2}&resetCode={3}", + url.Scheme, + url.Host + (url.Port == 80 ? string.Empty : ":" + url.Port), + userId, + HttpUtility.UrlEncode(code)); + } + + private string ConstructPasswordResetEmailMessage(IUser user, string callbackUrl) + { + var emailCopy1 = Services.TextService.Localize("login/resetPasswordEmailCopyFormat1"); + var emailCopy2 = Services.TextService.Localize("login/resetPasswordEmailCopyFormat2"); + var message = string.Format("

" + emailCopy1 + "

\n\n" + + "

" + emailCopy2 + "

", + user.Username, + callbackUrl); + return message; + } + + /// + /// 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 PostValidatePasswordResetCode(ValidatePasswordResetCodeModel model) + { + var user = UserManager.FindById(model.UserId); + if (user != null) + { + var result = await UserManager.UserTokenProvider.ValidateAsync("ResetPassword", + model.ResetCode, UserManager, user); + if (result) + { + return Request.CreateResponse(HttpStatusCode.OK); + } + } + + return Request.CreateValidationErrorResponse("Password reset code not valid"); + } + + /// + /// Processes a set password request. Validates the request and sets a new password. + /// + /// + [SetAngularAntiForgeryTokens] + public async Task PostSetPassword(SetPasswordModel model) + { + var result = await UserManager.ResetPasswordAsync(model.UserId, model.ResetCode, model.Password); + if (result.Succeeded) + { + return Request.CreateResponse(HttpStatusCode.OK); + } + + return Request.CreateValidationErrorResponse("Set password failed"); + } + + 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; + } /// /// Logs the current user out diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index 0b7a611fbb..297221dacc 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -361,6 +361,7 @@ namespace Umbraco.Web.Editors }, {"keepUserLoggedIn", UmbracoConfig.For.UmbracoSettings().Security.KeepUserLoggedIn}, {"cssPath", IOHelper.ResolveUrl(SystemDirectories.Css).TrimEnd('/')}, + {"allowPasswordReset", UmbracoConfig.For.UmbracoSettings().Security.AllowPasswordReset}, } }, { diff --git a/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs b/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs index 3f38ae35ec..2dc9841293 100644 --- a/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs +++ b/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs @@ -12,6 +12,8 @@ using Umbraco.Web.Editors; namespace Umbraco.Web { + using Umbraco.Core.Configuration; + /// /// HtmlHelper extensions for the back office /// @@ -43,6 +45,9 @@ namespace Umbraco.Web ""serverVarsJs"": """ + uri.GetUrlWithCacheBust("ServerVariables", "BackOffice") + @""", ""externalLoginsUrl"": """ + externalLoginsUrl + @""" }, + ""umbracoSettings"": { + ""allowPasswordReset"": " + (UmbracoConfig.For.UmbracoSettings().Security.AllowPasswordReset ? "true" : "false") + @" + }, ""application"": { ""applicationPath"": """ + html.ViewContext.HttpContext.Request.ApplicationPath + @""", ""version"": """ + version + @""", diff --git a/src/Umbraco.Web/Models/RequestPasswordResetModel.cs b/src/Umbraco.Web/Models/RequestPasswordResetModel.cs new file mode 100644 index 0000000000..650dd8ed11 --- /dev/null +++ b/src/Umbraco.Web/Models/RequestPasswordResetModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; + +namespace Umbraco.Web.Models +{ + public class RequestPasswordResetModel + { + [Required] + [DataMember(Name = "email", IsRequired = true)] + public string Email { get; set; } + } +} diff --git a/src/Umbraco.Web/Models/SetPasswordModel.cs b/src/Umbraco.Web/Models/SetPasswordModel.cs new file mode 100644 index 0000000000..cc70989d66 --- /dev/null +++ b/src/Umbraco.Web/Models/SetPasswordModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; + +namespace Umbraco.Web.Models +{ + public class SetPasswordModel + { + [Required] + [DataMember(Name = "userId", IsRequired = true)] + public int UserId { get; set; } + + [Required] + [DataMember(Name = "password", IsRequired = true)] + public string Password { get; set; } + + [Required] + [DataMember(Name = "resetCode", IsRequired = true)] + public string ResetCode { get; set; } + } +} diff --git a/src/Umbraco.Web/Models/ValidatePasswordResetCodeModel.cs b/src/Umbraco.Web/Models/ValidatePasswordResetCodeModel.cs new file mode 100644 index 0000000000..61db6907ef --- /dev/null +++ b/src/Umbraco.Web/Models/ValidatePasswordResetCodeModel.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; + +namespace Umbraco.Web.Models +{ + public class ValidatePasswordResetCodeModel + { + [Required] + [DataMember(Name = "userId", IsRequired = true)] + public int UserId { get; set; } + + [Required] + [DataMember(Name = "resetCode", IsRequired = true)] + public string ResetCode { get; set; } + } +} diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 3aeffcfe16..16b220b232 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -341,6 +341,9 @@ + + + diff --git a/src/umbraco.businesslogic/UmbracoSettings.cs b/src/umbraco.businesslogic/UmbracoSettings.cs index 3d489cfaa4..d73ea22844 100644 --- a/src/umbraco.businesslogic/UmbracoSettings.cs +++ b/src/umbraco.businesslogic/UmbracoSettings.cs @@ -102,6 +102,14 @@ namespace umbraco get { return UmbracoConfig.For.UmbracoSettings().Security.HideDisabledUsersInBackoffice; } } + /// + /// Enable the UI and API to allow back-office users to reset their passwords? Default is true + /// + public static bool AllowPasswordReset + { + get { return UmbracoConfig.For.UmbracoSettings().Security.AllowPasswordReset; } + } + /// /// Gets a value indicating whether the logs will be auto cleaned ///