Skip to content

Commit 148c2c6

Browse files
authored
feat: add member password reset (#100)
* chore: add usync content for new template * chore: regenerate models * feat: add forgot password flow * feat: add reset password flow
1 parent d8570c7 commit 148c2c6

58 files changed

Lines changed: 702 additions & 370 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

SgfDevs/Controllers/AccountController.cs

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,44 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Linq;
34
using System.Threading.Tasks;
4-
using J2N.Collections.Generic;
55
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.AspNetCore.WebUtilities;
7+
using Microsoft.Extensions.Logging;
8+
using Microsoft.Extensions.Options;
69
using SGFDevs.Models;
710
using SGFDevs.ViewModels;
11+
using Umbraco.Cms.Core.Configuration.Models;
812
using Umbraco.Cms.Core;
913
using Umbraco.Cms.Core.Cache;
1014
using Umbraco.Cms.Core.Logging;
15+
using Umbraco.Cms.Core.Mail;
16+
using Umbraco.Cms.Core.Models.Email;
17+
using Umbraco.Cms.Core.Models.PublishedContent;
1118
using Umbraco.Cms.Core.Routing;
1219
using Umbraco.Cms.Core.Security;
1320
using Umbraco.Cms.Core.Services;
1421
using Umbraco.Cms.Core.Web;
1522
using Umbraco.Cms.Infrastructure.Persistence;
23+
using Umbraco.Cms.Web.Common;
1624
using Umbraco.Cms.Web.Common.Models;
1725
using Umbraco.Cms.Web.Common.Filters;
1826
using Umbraco.Cms.Web.Common.Security;
1927
using Umbraco.Cms.Web.Website.Controllers;
28+
using Umbraco.Extensions;
2029

2130
namespace SGFDevs.Controllers;
2231

2332
[AutoValidateAntiforgeryToken]
2433
public class AccountController : SurfaceController
2534
{
26-
private IMemberSignInManager _memberSignInManager;
27-
private IMemberManager _memberManager;
28-
private IMemberService _memberService;
35+
private readonly IMemberSignInManager _memberSignInManager;
36+
private readonly IMemberManager _memberManager;
37+
private readonly IMemberService _memberService;
38+
private readonly UmbracoHelper _umbracoHelper;
39+
private readonly IEmailSender _emailSender;
40+
private readonly IOptions<GlobalSettings> _globalSettings;
41+
private readonly ILogger<AccountController> _logger;
2942

3043
public AccountController(
3144
IUmbracoContextAccessor umbracoContextAccessor,
@@ -36,12 +49,20 @@ public AccountController(
3649
IPublishedUrlProvider publishedUrlProvider,
3750
IMemberSignInManager memberSignInManager,
3851
IMemberManager memberManager,
39-
IMemberService memberService
52+
IMemberService memberService,
53+
UmbracoHelper umbracoHelper,
54+
IEmailSender emailSender,
55+
IOptions<GlobalSettings> globalSettings,
56+
ILogger<AccountController> logger
4057
) : base(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider)
4158
{
4259
_memberSignInManager = memberSignInManager;
4360
_memberManager = memberManager;
4461
_memberService = memberService;
62+
_umbracoHelper = umbracoHelper;
63+
_emailSender = emailSender;
64+
_globalSettings = globalSettings;
65+
_logger = logger;
4566
}
4667

4768
[HttpPost]
@@ -136,6 +157,101 @@ public async Task<IActionResult> Register(RegisterModel model)
136157
return CurrentUmbracoPage();
137158
}
138159

160+
[HttpPost]
161+
public async Task<IActionResult> ForgotPassword(ForgotPasswordModel model)
162+
{
163+
if (!ModelState.IsValid)
164+
return CurrentUmbracoPage();
165+
166+
if (_emailSender.CanSendRequiredEmail() == false)
167+
{
168+
ModelState.AddModelError(string.Empty, "Password reset is unavailable right now.");
169+
return CurrentUmbracoPage();
170+
}
171+
172+
var fromAddress = _globalSettings.Value.Smtp?.From;
173+
if (string.IsNullOrWhiteSpace(fromAddress))
174+
{
175+
ModelState.AddModelError(string.Empty, "Password reset is unavailable right now.");
176+
return CurrentUmbracoPage();
177+
}
178+
179+
var resetPage = _umbracoHelper
180+
.ContentAtRoot()
181+
.SelectMany(root => root.DescendantsOrSelf())
182+
.FirstOrDefault(content => content.ContentType.Alias == "resetPassword");
183+
184+
if (resetPage == null)
185+
{
186+
_logger.LogError("Unable to locate the reset password page in content.");
187+
ModelState.AddModelError(string.Empty, "Password reset is unavailable right now.");
188+
return CurrentUmbracoPage();
189+
}
190+
191+
var resetPageUrl = resetPage.Url(mode: UrlMode.Absolute);
192+
if (Uri.TryCreate(resetPageUrl, UriKind.Absolute, out _) == false)
193+
{
194+
_logger.LogError("Unable to build an absolute URL for the reset password page.");
195+
ModelState.AddModelError(string.Empty, "Password reset is unavailable right now.");
196+
return CurrentUmbracoPage();
197+
}
198+
199+
var member = await _memberManager.FindByEmailAsync(model.Email);
200+
if (member != null)
201+
{
202+
try
203+
{
204+
var token = await _memberManager.GeneratePasswordResetTokenAsync(member);
205+
var resetLink = QueryHelpers.AddQueryString(resetPageUrl, new Dictionary<string, string>
206+
{
207+
["memberId"] = member.Id,
208+
["token"] = token,
209+
});
210+
211+
var subject = "Reset your Springfield Devs password";
212+
var body = $"<p>Hi {member.Name},</p><p>Someone requested a password reset for your Springfield Devs account.</p><p>If that was you, use the link below to choose a new password:</p><p><a href=\"{resetLink}\">Reset your password</a></p><p>If you did not request this, you can ignore this email.</p>";
213+
var emailMessage = new EmailMessage(fromAddress, member.Email, subject, body, true);
214+
215+
await _emailSender.SendAsync(emailMessage, "PasswordReset", true, _globalSettings.Value.Smtp?.EmailExpiration);
216+
}
217+
catch (Exception ex)
218+
{
219+
_logger.LogError(ex, "Failed to send a password reset email for member {MemberId}.", member.Id);
220+
}
221+
}
222+
223+
TempData["ForgotPasswordMessage"] = "If an account exists for that email, we sent a reset link.";
224+
return Redirect("/forgotten-password");
225+
}
226+
227+
[HttpPost]
228+
public async Task<IActionResult> ResetPassword(ResetPasswordModel model)
229+
{
230+
if (!ModelState.IsValid)
231+
return CurrentUmbracoPage();
232+
233+
var member = await _memberManager.FindByIdAsync(model.MemberId);
234+
if (member == null)
235+
{
236+
ModelState.AddModelError(string.Empty, "This reset link is invalid or has expired.");
237+
return CurrentUmbracoPage();
238+
}
239+
240+
var result = await _memberManager.ResetPasswordAsync(member, model.Token, model.Password);
241+
if (!result.Succeeded)
242+
{
243+
foreach (var error in result.Errors)
244+
{
245+
ModelState.AddModelError(string.Empty, error.Description);
246+
}
247+
248+
return CurrentUmbracoPage();
249+
}
250+
251+
TempData["LoginMessage"] = "Your password has been updated. Please log in.";
252+
return Redirect("/login");
253+
}
254+
139255
[HttpPost]
140256
[UmbracoMemberAuthorize]
141257
public async Task<IActionResult> ProfileUpdate(MemberProfile profile)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace SGFDevs.Models;
4+
5+
public class ForgotPasswordModel
6+
{
7+
[Required]
8+
[EmailAddress]
9+
public string Email { get; set; }
10+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace SGFDevs.Models;
2+
3+
public static class PasswordValidationRules
4+
{
5+
public const string Pattern = "^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$";
6+
public const string ErrorMessage = "You know the drill.. Password must contain at least one upper and lowercase letter, a numeric digit, a special character, and be at least 8 characters long.";
7+
}

SgfDevs/Models/RegisterModel.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ public class RegisterModel
2222

2323
[Required]
2424
[DataType(DataType.Password)]
25-
[RegularExpression("^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$", ErrorMessage ="You know the drill.. Password must contain at least one upper and lowercase letter, a numeric digit, a special character, and be at least 8 characters long.")]
25+
[RegularExpression(PasswordValidationRules.Pattern, ErrorMessage = PasswordValidationRules.ErrorMessage)]
2626
public string Password { get; set; }
2727

2828
[Required]
2929
[Display(Name = "Null Check")]
3030
[RegularExpression("SGF|sgf", ErrorMessage = "Better luck next time.")]
3131
public string ChallengeQuestion { get; set; }
32-
}
32+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace SGFDevs.Models;
4+
5+
public class ResetPasswordModel
6+
{
7+
[Required]
8+
public string MemberId { get; set; }
9+
10+
[Required]
11+
public string Token { get; set; }
12+
13+
[Required]
14+
[DataType(DataType.Password)]
15+
[RegularExpression(PasswordValidationRules.Pattern, ErrorMessage = PasswordValidationRules.ErrorMessage)]
16+
public string Password { get; set; }
17+
18+
[Required]
19+
[DataType(DataType.Password)]
20+
[Compare(nameof(Password), ErrorMessage = "Passwords do not match.")]
21+
[Display(Name = "Confirm Password")]
22+
public string ConfirmPassword { get; set; }
23+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<SGFDevs.Models.ForgotPasswordModel>
2+
3+
<div class="container">
4+
@if (TempData["ForgotPasswordMessage"] is string message)
5+
{
6+
<div class="form">
7+
<header>
8+
<h1>Check your email</h1>
9+
<p>@message</p>
10+
<p><a href="/login">Back to login</a></p>
11+
</header>
12+
</div>
13+
}
14+
else
15+
{
16+
@using (Html.BeginUmbracoForm("ForgotPassword", "Account", FormMethod.Post))
17+
{
18+
<div class="form">
19+
<header>
20+
<h1>Forgot your password?</h1>
21+
<p>Enter the email address tied to your account and we will send you a reset link.</p>
22+
@Html.ValidationSummary(true)
23+
</header>
24+
25+
<div class="field">
26+
<label asp-for="Email"></label>
27+
<input asp-for="Email" class="form-control" />
28+
<span asp-validation-for="Email" class="text-danger"></span>
29+
</div>
30+
31+
<footer>
32+
<button class="button tall wide" type="submit">Send reset link</button>
33+
<p><a href="/login">Back to login</a></p>
34+
</footer>
35+
</div>
36+
}
37+
}
38+
</div>
39+
40+
<div class="mt_75"></div>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using SGFDevs.Models;
3+
4+
namespace SGFDevs.Views.Components.ForgottenPassword;
5+
6+
public class ForgottenPasswordViewComponent : ViewComponent
7+
{
8+
public IViewComponentResult Invoke()
9+
{
10+
return View(new ForgotPasswordModel());
11+
}
12+
}

SgfDevs/Views/Components/Login/Default.cshtml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44

55
<div class="container">
6+
@if (TempData["LoginMessage"] is string loginMessage)
7+
{
8+
<p>@loginMessage</p>
9+
}
610
<p>@ViewData["Login"]</p>
711
<p>@ViewData["invalid"]</p>
812
<p>@ViewData["LoginInvalid"]</p>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<SGFDevs.Models.ResetPasswordModel>
2+
3+
<div class="container">
4+
<div class="form">
5+
<header>
6+
<h1>Reset your password</h1>
7+
<p>Choose a new password for your Springfield Devs account.</p>
8+
</header>
9+
10+
@if (string.IsNullOrWhiteSpace(Model.MemberId) || string.IsNullOrWhiteSpace(Model.Token))
11+
{
12+
<p>This reset link is invalid or has expired.</p>
13+
<p><a href="/forgotten-password">Request a new reset link</a></p>
14+
}
15+
else
16+
{
17+
@using (Html.BeginUmbracoForm("ResetPassword", "Account", FormMethod.Post))
18+
{
19+
@Html.ValidationSummary(true)
20+
<input asp-for="MemberId" type="hidden" />
21+
<input asp-for="Token" type="hidden" />
22+
23+
<div class="field">
24+
<label asp-for="Password"></label>
25+
<input asp-for="Password" class="form-control" />
26+
<span asp-validation-for="Password" class="text-danger"></span>
27+
</div>
28+
29+
<div class="field">
30+
<label asp-for="ConfirmPassword"></label>
31+
<input asp-for="ConfirmPassword" class="form-control" />
32+
<span asp-validation-for="ConfirmPassword" class="text-danger"></span>
33+
</div>
34+
35+
<footer>
36+
<button class="button tall wide" type="submit">Update password</button>
37+
<p><a href="/login">Back to login</a></p>
38+
</footer>
39+
}
40+
}
41+
</div>
42+
</div>
43+
44+
<div class="mt_75"></div>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using SGFDevs.Models;
3+
4+
namespace SGFDevs.Views.Components.ResetPassword;
5+
6+
public class ResetPasswordViewComponent : ViewComponent
7+
{
8+
public IViewComponentResult Invoke(string memberId, string token)
9+
{
10+
return View(new ResetPasswordModel
11+
{
12+
MemberId = memberId,
13+
Token = token,
14+
});
15+
}
16+
}

0 commit comments

Comments
 (0)