diff --git a/WebCms/Controllers/ContactController.cs b/WebCms/Controllers/ContactController.cs index 1dad4fd..c4446da 100644 --- a/WebCms/Controllers/ContactController.cs +++ b/WebCms/Controllers/ContactController.cs @@ -1,4 +1,5 @@ using System.Web.Mvc; +using Hangfire; using log4net; using LeafWeb.WebCms.Models; using LeafWeb.WebCms.Services; @@ -18,10 +19,10 @@ namespace LeafWeb.WebCms.Controllers { if (ModelState.IsValid) // HttpParamMatch indicates it's backing out from Confirm { - // convert viewModel into Model - new EmailNotificationService().SendContactEmail(viewModel); + BackgroundJob.Enqueue( + e => e.SendContactEmail(viewModel)); - var logger = LogManager.GetLogger(GetType()); + var logger = LogManager.GetLogger(GetType()); logger.Info($"Contact: Name:{viewModel.Name} Added, Email:{viewModel.Email}, Message:{viewModel.Message}"); SetStatusMessage("Your message has been sent!", StatusType.Success); diff --git a/WebCms/Controllers/LeafWebPageIds.cs b/WebCms/Controllers/LeafWebPageIds.cs index 9545f9e..a9032fa 100644 --- a/WebCms/Controllers/LeafWebPageIds.cs +++ b/WebCms/Controllers/LeafWebPageIds.cs @@ -6,5 +6,12 @@ namespace LeafWeb.WebCms.Controllers public const int ManageQueue = 1107; public const int Chart = 1100; public const int Details = 1111; - } + public const int PasswordResetRequest = 1164; + } + + public static class LeafWebMemberProperties + { + public const string VerificationToken = "VerificationToken"; + public const string PasswordResetToken = "PasswordResetToken"; + } } \ No newline at end of file diff --git a/WebCms/Controllers/MembershipController.cs b/WebCms/Controllers/MembershipController.cs index db42263..5e17e55 100644 --- a/WebCms/Controllers/MembershipController.cs +++ b/WebCms/Controllers/MembershipController.cs @@ -1,4 +1,8 @@ using System.Web.Mvc; +using Hangfire; +using LeafWeb.WebCms.Models; +using LeafWeb.WebCms.Services; +using MlkPwgen; using Umbraco.Core; namespace LeafWeb.WebCms.Controllers @@ -23,7 +27,7 @@ namespace LeafWeb.WebCms.Controllers } else { - var storedToken = member.GetValue("VerificationToken") as string; + var storedToken = member.GetValue(LeafWebMemberProperties.VerificationToken); if (string.IsNullOrEmpty(storedToken)) { @@ -41,7 +45,7 @@ namespace LeafWeb.WebCms.Controllers member.IsApproved = true; // remove the verification - member.SetValue("VerificationToken", string.Empty); + member.SetValue(LeafWebMemberProperties.VerificationToken, string.Empty); memberService.Save(member); TempData["StatusMessage"] = "Thank you! Your email is now verified at " + member.Email; @@ -53,5 +57,137 @@ namespace LeafWeb.WebCms.Controllers return Redirect(redirectUrl); } + + public ActionResult PasswordResetRequest() + { + var viewModel = new PasswordResetRequestForm(); + return PartialView("PasswordResetRequest", viewModel); + } + + [HttpPost] + public ActionResult PasswordResetRequest(PasswordResetRequestForm requestForm) + { + if (!ModelState.IsValid) + return CurrentUmbracoPage(); + + var memberService = ApplicationContext.Current.Services.MemberService; + var member = memberService.GetByEmail(requestForm.Email); + if (member == null) + { + // Send notification of attempt to change + BackgroundJob.Enqueue( + e => e.SendPasswordResetNotMemberEmail(requestForm.Email)); + } + else + { + var token = PasswordGenerator.Generate(12, allowed: "0123456789"); + member.SetValue(LeafWebMemberProperties.PasswordResetToken, token); + memberService.Save(member); + + // Send Email + BackgroundJob.Enqueue( + e => e.SendPasswordResetEmail(member.Email)); + } + + // don't acknowledge their email address + TempData["StatusMessage"] = + $"An email has been sent to {requestForm.Email} with instructions on how to reset your password."; + TempData["StatusMessage-Type"] = "alert-success"; + + return Redirect("/"); + } + + public ActionResult PasswordReset(string email, string token) + { + var errorMsg = $"Sorry, a valid password reset was not found for user {email}. " + + $"Please try resetting again, " + + $"or use Contact Us if the issue persists."; + + if (!string.IsNullOrEmpty(email) && !string.IsNullOrEmpty(token)) + { + + var memberService = ApplicationContext.Current.Services.MemberService; + var member = memberService.GetByEmail(email); + if (member == null) + { + // don't acknowledge their email address + TempData["StatusMessage"] = errorMsg; + TempData["StatusMessage-Type"] = "alert-danger"; + } + else + { + var storedToken = member.GetValue(LeafWebMemberProperties.PasswordResetToken); + + if (string.IsNullOrEmpty(storedToken)) + { + TempData["StatusMessage"] = errorMsg; + TempData["StatusMessage-Type"] = "alert-danger"; + } + else if (storedToken != token) + { + TempData["StatusMessage"] = errorMsg; + TempData["StatusMessage-Type"] = "alert-danger"; + } + else + { + var viewModel = new PasswordResetForm {Email = email, PasswordResetToken = token}; + return PartialView(viewModel); + } + } + } + + return PasswordResetRequest(); + } + + [HttpPost] + public ActionResult PasswordReset(PasswordResetForm form) + { + var redirectUrl = "/"; + + var errorMsg = $"Sorry, a valid password reset was not found for user {form.Email}. " + + $"Please try resetting again, " + + $"or use Contact Us if the issue persists."; + + if (ModelState.IsValid) + { + var memberService = ApplicationContext.Current.Services.MemberService; + var member = memberService.GetByEmail(form.Email); + if (member == null) + { + // don't acknowledge their email address + TempData["StatusMessage"] = errorMsg; + TempData["StatusMessage-Type"] = "alert-danger"; + } + else + { + var storedToken = member.GetValue(LeafWebMemberProperties.PasswordResetToken); + if (string.IsNullOrEmpty(storedToken)) + { + TempData["StatusMessage"] = errorMsg; + TempData["StatusMessage-Type"] = "alert-danger"; + } + else if (storedToken != form.PasswordResetToken) + { + TempData["StatusMessage"] = errorMsg; + TempData["StatusMessage-Type"] = "alert-danger"; + } + else + { + memberService.SavePassword(member, form.Password); + + // remove the token + member.SetValue(LeafWebMemberProperties.PasswordResetToken, string.Empty); + memberService.Save(member); + + TempData["StatusMessage"] = "Password updated for " + member.Email + ", use your new password to login."; + TempData["StatusMessage-Type"] = "alert-success"; + + redirectUrl = "membership/login"; + } + } + } + + return Redirect(redirectUrl); + } } } \ No newline at end of file diff --git a/WebCms/EventHandlers/MemberEvents.cs b/WebCms/EventHandlers/MemberEvents.cs index 7f58ed4..b90cb9a 100644 --- a/WebCms/EventHandlers/MemberEvents.cs +++ b/WebCms/EventHandlers/MemberEvents.cs @@ -1,4 +1,5 @@ using Hangfire; +using LeafWeb.WebCms.Controllers; using LeafWeb.WebCms.Services; using MlkPwgen; using Umbraco.Core; @@ -41,7 +42,7 @@ namespace LeafWeb.WebCms.EventHandlers { e.Entity.IsApproved = false; var token = PasswordGenerator.Generate(12, allowed: "0123456789"); - e.Entity.SetValue("VerificationToken", token); + e.Entity.SetValue(LeafWebMemberProperties.VerificationToken, token); sender.Save(e.Entity); // Send Email diff --git a/WebCms/Models/LeafInputCreate.cs b/WebCms/Models/LeafInputCreate.cs index 7f4d5d2..fb6ec58 100644 --- a/WebCms/Models/LeafInputCreate.cs +++ b/WebCms/Models/LeafInputCreate.cs @@ -21,7 +21,7 @@ namespace LeafWeb.WebCms.Models [Display(Name = "Confirm email address")] [Required(ErrorMessage = "Enter email exactly as above")] - [System.ComponentModel.DataAnnotations.Compare("Email")] + [Compare("Email")] public string EmailConfirm { get; set; } [Display(Name = "A unique identifier for this data")] diff --git a/WebCms/Models/PasswordResetForm.cs b/WebCms/Models/PasswordResetForm.cs new file mode 100644 index 0000000..d52562e --- /dev/null +++ b/WebCms/Models/PasswordResetForm.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace LeafWeb.WebCms.Models +{ + public class PasswordResetForm + { + [Display(Name = "Email address")] + [Required(ErrorMessage = "An email address is required")] + [DataType(DataType.EmailAddress)] + [RegularExpression(@"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}", ErrorMessage = "Must be an email address")] + public string Email { get; set; } + + [Required(ErrorMessage = "A token is required to reset")] + public string PasswordResetToken { get; set; } + + [Required(ErrorMessage = "A new password is required")] + [DataType(DataType.Password)] + public string Password { get; set; } + + [Required(ErrorMessage = "Password must match verify exactly")] + [DataType(DataType.Password)] + [Compare("Password")] + public string PasswordVerify { get; set; } + } +} \ No newline at end of file diff --git a/WebCms/Models/PasswordResetRequestForm.cs b/WebCms/Models/PasswordResetRequestForm.cs new file mode 100644 index 0000000..2c952ba --- /dev/null +++ b/WebCms/Models/PasswordResetRequestForm.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace LeafWeb.WebCms.Models +{ + public class PasswordResetRequestForm + { + [Display(Name = "Email address")] + [Required(ErrorMessage = "An email address is required")] + [DataType(DataType.EmailAddress)] + [RegularExpression(@"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}", ErrorMessage = "Must be an email address")] + public string Email { get; set; } + } +} \ No newline at end of file diff --git a/WebCms/Services/EmailNotificationService.cs b/WebCms/Services/EmailNotificationService.cs index 5d0adff..cbb5eef 100644 --- a/WebCms/Services/EmailNotificationService.cs +++ b/WebCms/Services/EmailNotificationService.cs @@ -94,14 +94,24 @@ namespace LeafWeb.WebCms.Services var body = $"Your leaf analysis job, {leafInput.Identifier}, has been cancelled. " + "Contact the administrator with any questions."; - var message = new MailMessage(_emailFromAddress, leafInput.Email, FormatSubject(CancelledSubject, leafInput), body); - SendMessage(message); + using (var message = + new MailMessage( + _emailFromAddress, + leafInput.Email, + FormatSubject(CancelledSubject, leafInput), + body)) + SendMessage(message); } public void SendAdministratorMessage(string subject, string body) { - var message = new MailMessage(_emailFromAddress, _adminEmailAddresses, subject, body); - SendMessage(message); + using (var message = + new MailMessage( + _emailFromAddress, + _adminEmailAddresses, + subject, body + )) + SendMessage(message); } private void SendLeafWebSuccess(LeafInput leafInput) @@ -129,8 +139,13 @@ namespace LeafWeb.WebCms.Services + Environment.NewLine + Environment.NewLine + chartUrl; - var message = new MailMessage(_emailFromAddress, leafInput.Email, FormatSubject(SuccessSubject, leafInput), body); - SendMessage(message); + using (var message = + new MailMessage( + _emailFromAddress, + leafInput.Email, + FormatSubject(SuccessSubject, leafInput), + body)) + SendMessage(message); } //else //{ @@ -173,8 +188,13 @@ namespace LeafWeb.WebCms.Services body += FormatWarningMessage(leafInput); - var message = new MailMessage(_emailFromAddress, leafInput.Email, FormatSubject(ErrorSubject, leafInput), body); - SendMessage(message); + using (var message = + new MailMessage( + _emailFromAddress, + leafInput.Email, + FormatSubject(ErrorSubject, leafInput), + body)) + SendMessage(message); } public void SendLeafWebSystemException(string leafInputIdentifier, string leafInputEmail) @@ -183,8 +203,13 @@ namespace LeafWeb.WebCms.Services + "System administrators have been notified. You will be notified again when the system error " + "has been resolved and your data has been processed."; - var message = new MailMessage(_emailFromAddress, leafInputEmail, SystemErrorSubject, body); - SendMessage(message); + using (var message = + new MailMessage( + _emailFromAddress, + leafInputEmail, + SystemErrorSubject, + body)) + SendMessage(message); } public void SendContactEmail(ContactForm contact) @@ -198,24 +223,65 @@ namespace LeafWeb.WebCms.Services Environment.NewLine + Environment.NewLine + contact.Message; - var message = new MailMessage(_emailFromAddress, _adminEmailAddresses, ContactSubject, body) - { - From = new MailAddress(contact.Email, contact.Name) - }; - SendMessage(message); + using (var message = + new MailMessage( + _emailFromAddress, + _adminEmailAddresses, + ContactSubject, body) + { + From = new MailAddress(contact.Email, contact.Name) + }) + SendMessage(message); } public void SendVerifyMemberEmail(string memberEmail) { var member = ApplicationContext.Current.Services.MemberService.GetByEmail(memberEmail); - var verifyEmailURl = _urlService.GetVerifyEmailURl(member); + var verifyEmailURl = _urlService.GetVerifyEmailUrl(member); var body = "Welcome to LeafWeb!" + Environment.NewLine + Environment.NewLine + "Please verify your email address with this link " + verifyEmailURl + Environment.NewLine + "Read more information about LeafWeb on the site here: https://leafweb.org/information/about/"; - var message = new MailMessage(_emailFromAddress, member.Email, "Welcome to LeafWeb, please verify your email address", body); - SendMessage(message); + using (var message = + new MailMessage( + _emailFromAddress, + member.Email, + "Welcome to LeafWeb, please verify your email address", + body)) + SendMessage(message); + } + + public void SendPasswordResetNotMemberEmail(string memberEmail) + { + var body = + "A password reset has been requested for leafweb.org" + Environment.NewLine + Environment.NewLine + + "We do not have an account attached to this email address, " + + "if you'd like to create one click here " + _urlService.GetRegisterUrl(); + + using (var message = + new MailMessage( + _emailFromAddress, + memberEmail, + "Reset LeafWeb Password", + body)) + SendMessage(message); + } + public void SendPasswordResetEmail(string memberEmail) + { + var member = ApplicationContext.Current.Services.MemberService.GetByEmail(memberEmail); + var passwordResetURl = _urlService.GetPasswordResetUrl(member); + var body = + "A password reset has been requested for leafweb.org" + Environment.NewLine + Environment.NewLine + + "Please click here to enter a new password " + passwordResetURl; + + using (var message = + new MailMessage( + _emailFromAddress, + member.Email, + "Reset LeafWeb Password", + body)) + SendMessage(message); } private string FormatWarningMessage(LeafInput leafInput) @@ -246,6 +312,5 @@ namespace LeafWeb.WebCms.Services { _dataService.Dispose(); } - } } \ No newline at end of file diff --git a/WebCms/Services/UrlService.cs b/WebCms/Services/UrlService.cs index 2bdf267..003da0f 100644 --- a/WebCms/Services/UrlService.cs +++ b/WebCms/Services/UrlService.cs @@ -1,6 +1,7 @@ using System.Configuration; using System.Web; using LeafWeb.Core.Entities; +using LeafWeb.WebCms.Controllers; using Umbraco.Core.Models; namespace LeafWeb.WebCms.Services @@ -10,6 +11,8 @@ namespace LeafWeb.WebCms.Services private readonly string _downloadUrl; private readonly string _chartUrl; private readonly string _verifyEmailUrl; + private readonly string _passwordResetUrl; + private readonly string _registerUrl; public UrlService() { @@ -24,9 +27,17 @@ namespace LeafWeb.WebCms.Services _verifyEmailUrl = ConfigurationManager.AppSettings["LeafWebUrl"] + ConfigurationManager.AppSettings["MemberVerifyPath"]; + + _passwordResetUrl = + ConfigurationManager.AppSettings["LeafWebUrl"] + + ConfigurationManager.AppSettings["PasswordResetPath"]; + + _registerUrl = + ConfigurationManager.AppSettings["LeafWebUrl"] + + ConfigurationManager.AppSettings["RegisterPath"]; } - public string GetDownloadUrl(LeafInput leafInput) + public string GetDownloadUrl(LeafInput leafInput) { return string.Format(_downloadUrl, leafInput.UniqueToken); } @@ -36,11 +47,20 @@ namespace LeafWeb.WebCms.Services return string.Format(_chartUrl, leafInput.UniqueToken); } - public string GetVerifyEmailURl(IMember member) + public string GetVerifyEmailUrl(IMember member) { var memberEmail = member.Email; - var token = member.GetValue("VerificationToken") as string; + var token = member.GetValue(LeafWebMemberProperties.VerificationToken); return string.Format(_verifyEmailUrl, HttpUtility.UrlEncode(memberEmail), token); } + + public string GetPasswordResetUrl(IMember member) + { + var memberEmail = member.Email; + var token = member.GetValue(LeafWebMemberProperties.PasswordResetToken); + return string.Format(_passwordResetUrl, HttpUtility.UrlEncode(memberEmail), token); + } + + public string GetRegisterUrl() => _registerUrl; } } \ No newline at end of file diff --git a/WebCms/Views/Contact/Index.cshtml b/WebCms/Views/Contact/Index.cshtml index f10e885..a361868 100644 --- a/WebCms/Views/Contact/Index.cshtml +++ b/WebCms/Views/Contact/Index.cshtml @@ -18,7 +18,7 @@ @Html.EditorFor(m => m.Name) @Html.EditorFor(m => m.Email) @Html.EditorFor(m => m.Message) - } + } diff --git a/WebCms/Views/MacroPartials/Membership/Login.cshtml b/WebCms/Views/MacroPartials/Membership/Login.cshtml index 7cddeb1..9272741 100644 --- a/WebCms/Views/MacroPartials/Membership/Login.cshtml +++ b/WebCms/Views/MacroPartials/Membership/Login.cshtml @@ -13,8 +13,7 @@ @using (Html.BeginUmbracoForm("HandleLogin")) { @Html.EditorFor(m => loginModel.Username) - @Html.EditorFor(m => loginModel.Password, "Password") - + @Html.EditorFor(m => loginModel.Password, "PasswordWithForgotLink") @Html.ValidationSummary("loginModel", true) diff --git a/WebCms/Views/MacroPartials/Membership/PasswordReset.cshtml b/WebCms/Views/MacroPartials/Membership/PasswordReset.cshtml new file mode 100644 index 0000000..76fe340 --- /dev/null +++ b/WebCms/Views/MacroPartials/Membership/PasswordReset.cshtml @@ -0,0 +1,4 @@ +@inherits Umbraco.Web.Macros.PartialViewMacroPage +@{ + Html.RenderAction("PasswordReset", "Membership"); +} \ No newline at end of file diff --git a/WebCms/Views/MacroPartials/Membership/PasswordResetRequest.cshtml b/WebCms/Views/MacroPartials/Membership/PasswordResetRequest.cshtml new file mode 100644 index 0000000..4f5f202 --- /dev/null +++ b/WebCms/Views/MacroPartials/Membership/PasswordResetRequest.cshtml @@ -0,0 +1,4 @@ +@inherits Umbraco.Web.Macros.PartialViewMacroPage +@{ + Html.RenderAction("PasswordResetRequest", "Membership"); +} \ No newline at end of file diff --git a/WebCms/Views/MacroPartials/Membership/Register.cshtml b/WebCms/Views/MacroPartials/Membership/Register.cshtml index 990c0be..1122b3e 100644 --- a/WebCms/Views/MacroPartials/Membership/Register.cshtml +++ b/WebCms/Views/MacroPartials/Membership/Register.cshtml @@ -1,9 +1,10 @@ @inherits Umbraco.Web.Macros.PartialViewMacroPage @using ClientDependency.Core.Mvc @using Umbraco.Web.Controllers - - @{ + // https://24days.in/umbraco-cms/2015/membership-apis-investigation/ + // https://our.umbraco.com/forum/templates-partial-views-and-macros/93133-membership-provider-registration-form + var membershipHelper = new Umbraco.Web.Security.MembershipHelper(UmbracoContext.Current); var registerModel = membershipHelper.CreateRegistrationModel(); @@ -28,7 +29,7 @@ } else if (user != null) { -

No need to register - you are already logged withe email @user.Email

+

No need to register - you are already logged with email @user.Email

} else { diff --git a/WebCms/Views/Membership/PasswordReset.cshtml b/WebCms/Views/Membership/PasswordReset.cshtml new file mode 100644 index 0000000..aa3df3c --- /dev/null +++ b/WebCms/Views/Membership/PasswordReset.cshtml @@ -0,0 +1,28 @@ +@using ClientDependency.Core.Mvc +@using LeafWeb.WebCms.Controllers +@model PasswordResetForm +@{ + Html.RequiresJs("~/scripts/jquery.validate.min.js", 2); + Html.RequiresJs("~/scripts/jquery.validate.unobtrusive.min.js", 2); + Html.RequiresJs("~/scripts/jquery.validate.unobtrusive.bootstrap.js", 2); + Html.RequiresJs("~/scripts/jquery.validate.custom.js", 2); +} + +
+
+
+

Resetting password for @Model.Email

+ @Html.Partial("_ValidationSummary") + + @using (Html.BeginUmbracoForm("PasswordReset")) + { + @Html.HiddenFor(m => m.Email) + @Html.HiddenFor(m => m.PasswordResetToken) + @Html.EditorFor(m => m.Password) + @Html.EditorFor(m => m.PasswordVerify) + + } + +
+
+
\ No newline at end of file diff --git a/WebCms/Views/Membership/PasswordResetRequest.cshtml b/WebCms/Views/Membership/PasswordResetRequest.cshtml new file mode 100644 index 0000000..de18991 --- /dev/null +++ b/WebCms/Views/Membership/PasswordResetRequest.cshtml @@ -0,0 +1,24 @@ +@using ClientDependency.Core.Mvc +@using LeafWeb.WebCms.Controllers +@model PasswordResetRequestForm +@{ + Html.RequiresJs("~/scripts/jquery.validate.min.js", 2); + Html.RequiresJs("~/scripts/jquery.validate.unobtrusive.min.js", 2); + Html.RequiresJs("~/scripts/jquery.validate.unobtrusive.bootstrap.js", 2); + Html.RequiresJs("~/scripts/jquery.validate.custom.js", 2); +} + +
+
+
+ @Html.Partial("_ValidationSummary") + + @using (Html.BeginUmbracoForm("PasswordResetRequest")) + { + @Html.EditorFor(m => m.Email) + + } + +
+
+
diff --git a/WebCms/Views/Shared/EditorTemplates/PasswordWithForgotLink.cshtml b/WebCms/Views/Shared/EditorTemplates/PasswordWithForgotLink.cshtml new file mode 100644 index 0000000..f040bec --- /dev/null +++ b/WebCms/Views/Shared/EditorTemplates/PasswordWithForgotLink.cshtml @@ -0,0 +1,24 @@ +@model object +@{ + Layout = "_FieldLayout.cshtml"; +} +@{ + var htmlAttributes = new RouteValueDictionary(); + + var controlClass = "form-control mb-0"; + + if (ViewBag.@class != null) + { + controlClass = string.Concat(controlClass, " ", ViewBag.@class); + } + + if (ViewData.ModelState.ContainsKey(ViewData.TemplateInfo.HtmlFieldPrefix) && + ViewData.ModelState[ViewData.TemplateInfo.HtmlFieldPrefix].Errors.Any()) + { + controlClass = string.Concat(controlClass, " ", "is-invalid"); + } + htmlAttributes.Add("class", controlClass); +} + +@Html.Password("", ViewData.TemplateInfo.FormattedModelValue, htmlAttributes) +

Forgot password? Reset here

diff --git a/WebCms/Web.config b/WebCms/Web.config index 06619ed..3e53666 100644 --- a/WebCms/Web.config +++ b/WebCms/Web.config @@ -76,8 +76,10 @@ + + @@ -262,7 +264,7 @@ - + diff --git a/WebCms/WebCms.csproj b/WebCms/WebCms.csproj index b429fa8..b6dd87f 100644 --- a/WebCms/WebCms.csproj +++ b/WebCms/WebCms.csproj @@ -1063,6 +1063,11 @@ + + + + + Web.config @@ -1101,6 +1106,8 @@ + + @@ -1128,7 +1135,6 @@ -