Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Password recovery (#313) #644

Merged
merged 2 commits into from
Jun 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
using IdentityServer4.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using OutOfSchool.EmailSender;
using OutOfSchool.IdentityServer.Controllers;
using OutOfSchool.IdentityServer.ViewModels;
using Microsoft.AspNetCore.Identity;
using OutOfSchool.Services.Models;
using System.Threading.Tasks;
using OutOfSchool.RazorTemplatesData.Services;

namespace OutOfSchool.IdentityServer.Tests.Controllers
{
public class AccountControllerTests
{
private AccountController accountController;
private readonly Mock<FakeSignInManager> fakeSignInManager;
private readonly Mock<FakeUserManager> fakeUserManager;
private readonly Mock<IEmailSender> fakeEmailSender;
private readonly Mock<ILogger<AccountController>> fakeLogger;
private readonly Mock<IStringLocalizer<SharedResource>> fakeLocalizer;
private readonly Mock<IRazorViewToStringRenderer> fakeRazorViewToStringRenderer;

public AccountControllerTests()
{
fakeSignInManager = new Mock<FakeSignInManager>();
fakeUserManager = new Mock<FakeUserManager>();
fakeEmailSender = new Mock<IEmailSender>();
fakeLogger = new Mock<ILogger<AccountController>>();
fakeLocalizer = new Mock<IStringLocalizer<SharedResource>>();
fakeRazorViewToStringRenderer = new Mock<IRazorViewToStringRenderer>();
}

[SetUp]
public void Setup()
{
fakeLocalizer
.Setup(localizer => localizer[It.IsAny<string>()])
.Returns(new LocalizedString("mock", "error"));

accountController = new AccountController(
fakeSignInManager.Object,
fakeUserManager.Object,
fakeEmailSender.Object,
fakeLogger.Object,
fakeLocalizer.Object,
fakeRazorViewToStringRenderer.Object
);
}

[Test]
public void ForgotPassword_ReturnsViewResult()
{
// Arrange
var returnUrl = "Return url";

// Act
IActionResult result = accountController.ForgotPassword(returnUrl);
var forgotPasswordResultModel = (ForgotPasswordViewModel)((ViewResult)result).Model;

// Assert
Assert.That(forgotPasswordResultModel.ReturnUrl, Is.EqualTo(returnUrl));
Assert.IsInstanceOf<ViewResult>(result);
}

#region ResetPasswordTests

[Test]
public async Task ResetPasswordGet_EmptyFields_ReturnsBadViewResult()
{
// Arrange

// Act
IActionResult result = await accountController.ResetPassword(null, null);

// Assert
Assert.IsInstanceOf<BadRequestObjectResult>(result);
}

[Test]
public async Task ResetPasswordGet_EmailNotFound_ReturnsErrorMessage()
{
// Arrange
fakeUserManager.Setup(userManager => userManager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((User)null);

// Act
IActionResult result = await accountController.ResetPassword("token", "email");
string modelMessage = (LocalizedString)((ViewResult)result).Model;

// Assert
Assert.AreEqual("error", modelMessage);
}

[Test]
public async Task ResetPasswordGet_TokenInvalid_ReturnsErrorMessage()
{
// Arrange
fakeUserManager.Setup(userManager => userManager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync(new User());

fakeUserManager.Setup(userManager => userManager.VerifyUserTokenAsync(It.IsAny<User>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(false);

// Act
IActionResult result = await accountController.ResetPassword("token", "email");
string modelMessage = (LocalizedString)((ViewResult)result).Model;

// Assert
Assert.AreEqual("error", modelMessage);
}

[Test]
public async Task ResetPasswordGet_TokenValid_ReturnsResetPasswordViewModel()
{
// Arrange
var token = "token";
var email = "[email protected]";
fakeUserManager.Setup(userManager => userManager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync(new User());

fakeUserManager.Setup(userManager => userManager.VerifyUserTokenAsync(It.IsAny<User>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(true);

// Act
IActionResult result = await accountController.ResetPassword(token, email);
ResetPasswordViewModel resetPasswordViewModel = (ResetPasswordViewModel)((ViewResult)result).Model;

// Assert
Assert.AreEqual(resetPasswordViewModel.Token, token);
Assert.AreEqual(resetPasswordViewModel.Email, email);
}

[Test]
public async Task ResetPasswordPost_InvalidModel_ReturnsResetPasswordViewModel()
{
// Arrange
var fakeErrorMessage = "Model is invalid";
accountController.ModelState.AddModelError(string.Empty, fakeErrorMessage);

// Act
IActionResult result = await accountController.ResetPassword(new ResetPasswordViewModel());
ResetPasswordViewModel resetPasswordViewModelResult = (ResetPasswordViewModel)((ViewResult)result).Model;

// Assert
Assert.IsInstanceOf<ResetPasswordViewModel>(resetPasswordViewModelResult);
}

[Test]
public async Task ResetPasswordPost_EmailNotFound_ReturnsResetPasswordViewModel()
{
// Arrange
fakeUserManager.Setup(userManager => userManager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((User)null);

// Act
IActionResult result = await accountController.ResetPassword(new ResetPasswordViewModel());
string modelMessage = (LocalizedString)((ViewResult)result).Model;

// Assert
Assert.AreEqual("error", modelMessage);
}

[Test]
public async Task ResetPasswordPost_Success_ReturnsResetPasswordConfirmation()
{
// Arrange
fakeUserManager.Setup(userManager => userManager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync(new User());
fakeUserManager.Setup(userManager => userManager.ResetPasswordAsync(It.IsAny<User>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(IdentityResult.Success);

// Act
IActionResult result = await accountController.ResetPassword(new ResetPasswordViewModel());
ViewResult viewResult = (ViewResult)result;

// Assert
Assert.AreEqual("Password/ResetPasswordConfirmation", viewResult.ViewName);
}

[Test]
public async Task ResetPasswordPost_Failed_ReturnsResetPasswordConfirmation()
{
// Arrange
fakeUserManager.Setup(userManager => userManager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync(new User());
fakeUserManager.Setup(userManager => userManager.ResetPasswordAsync(It.IsAny<User>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(IdentityResult.Failed(null));

// Act
IActionResult result = await accountController.ResetPassword(new ResetPasswordViewModel());
ViewResult viewResult = (ViewResult)result;

// Assert
Assert.AreEqual("Password/ResetPasswordFailed", viewResult.ViewName);
}
#endregion

}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Linq;
using System;
using System.Linq;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
Expand Down Expand Up @@ -40,6 +41,7 @@ public AccountController(
this.userManager = userManager;
this.emailSender = emailSender;
this.logger = logger;
this.localizer = localizer;
this.renderer = renderer;
this.localizer = localizer;
}
Expand Down Expand Up @@ -263,15 +265,15 @@ public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
}

var token = await userManager.GeneratePasswordResetTokenAsync(user);
var callBackUrl = Url.Action("ResetPassword", "Account", new { token }, Request.Scheme);
var callBackUrl = Url.Action("ResetPassword", "Account", new { token, user.Email }, Request.Scheme);

var email = model.Email;
var subject = "Reset Password";
var userActionViewModel = new UserActionViewModel
{
FirstName = user.FirstName,
LastName = user.LastName,
ActionUrl = HtmlEncoder.Default.Encode(callBackUrl),
ActionUrl = callBackUrl,
};

var htmlMessage = await renderer.GetHtmlStringAsync(RazorTemplates.ResetPassword, userActionViewModel);
Expand All @@ -284,17 +286,35 @@ public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
}

[HttpGet]
public IActionResult ResetPassword(string token = null)
public async Task<IActionResult> ResetPassword(string token = null, string email = null)
{
logger.LogDebug($"{path} started. User(id): {userId}");

if (token == null)
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(email))
{
logger.LogError($"{path} Token or email was not supplied for reset password. User(id): {userId}");
return BadRequest("A token and email must be supplied for password reset.");
}

var user = await userManager.FindByEmailAsync(email);
if (user == null)
{
logger.LogError($"{path} User not found. Email: {email}");

// If message will be "user not found", someone can use this url to check registered emails. I decide to show "invalid token"
return View("Password/ResetPasswordFailed", localizer["Change password invalid token"]);
}

var purpose = UserManager<User>.ResetPasswordTokenPurpose;
var checkToken = await userManager.VerifyUserTokenAsync(user, userManager.Options.Tokens.PasswordResetTokenProvider, purpose, token);
if (!checkToken)
{
logger.LogError($"{path} Token was not supplied for reset password. User(id): {userId}");
return BadRequest("A token must be supplied for password reset.");
logger.LogError($"{path} Token is not valid for user: {user.Id}");

return View("Password/ResetPasswordFailed", localizer["Change password invalid token"]);
}

return View("Password/ResetPassword", new ResetPasswordViewModel() { Token = token });
return View("Password/ResetPassword", new ResetPasswordViewModel() { Token = token, Email = email });
}

[HttpPost]
Expand All @@ -306,15 +326,15 @@ public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
{
logger.LogError($"{path} Input data was not valid. User(id): {userId}");

return BadRequest(ModelState);
return View("Password/ResetPassword", new ResetPasswordViewModel());
}

var user = await userManager.FindByEmailAsync(model.Email);
if (user == null)
{
logger.LogError($"{path} User with Email:{model.Email} was not found. User(id): {userId}");

return View("Password/ResetPasswordConfirmation");
return View("Password/ResetPasswordFailed", localizer["Change password failed"]);
}

var result = await userManager.ResetPasswordAsync(user, model.Token, model.Password);
Expand All @@ -328,8 +348,7 @@ public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
logger.LogError($"{path} Reset password was failed. User(id): {userId}. " +
$"{string.Join(System.Environment.NewLine, result.Errors.Select(e => e.Description))}");

// TODO: In my opinion we shouldn't return Ok in this cause.
return Ok();
return View("Password/ResetPasswordFailed", localizer["Change password failed"]);
}

[HttpGet]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,4 +327,13 @@
<data name="Change email mismatch current email {0}" xml:space="preserve">
<value>Email {0} dont match with current email</value>
</data>
</root>
<data name="Change password email message" xml:space="preserve">
<value>Confirm password change</value>
</data>
<data name="Change password invalid token" xml:space="preserve">
<value>Token is not valid</value>
</data>
<data name="Change password failed" xml:space="preserve">
<value>Change password failed. Please contact administrator.</value>
</data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@
<value>Введіть ваш емейл на який буде надіслано інструкцію для скидання пароля</value>
</data>
<data name="Check your email for instructions" xml:space="preserve">
<value>Перевірте вашу пошту для отримання інструкцій</value>
<value>Інструкція з відновлення паролю була надіслана на ваш емейл.
Зайдіть на емейл і виконайте дії вказані в листі.</value>
</data>
<data name="Reset password confirmation" xml:space="preserve">
<value>Підтвердження зміни паролю</value>
Expand Down Expand Up @@ -332,4 +333,13 @@ Login: {0}
<data name="Change email mismatch current email {0}" xml:space="preserve">
<value>Вказаний email {0} не співпадеє з поточним</value>
</data>
</root>
<data name="Change password email message" xml:space="preserve">
<value>Підтвердіть зміну паролю </value>
</data>
<data name="Change password invalid token" xml:space="preserve">
<value>Token не пройшов перевірку</value>
</data>
<data name="Change password failed" xml:space="preserve">
<value>Помилка при зміні паролю. Зверниться до адміністратора.</value>
</data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@
<h2 class="title">@SharedLocalizer["Reset password confirmation"]</h2>

<h3 class="instruction">@SharedLocalizer["Check your email for instructions"]</h3>

<div class="title">
<span>Згадали пароль? <a class="link" asp-controller="Auth" asp-action="Login">@SharedLocalizer["Sign In"]</a></span>
</div>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<div asp-validation-summary="ModelOnly" class="text-danger"></div>

<label class="registration_label">@SharedLocalizer["Email"]</label>
<input class="registration_input" asp-for="Email" autocomplete="username" aria-required="true" />
<input class="registration_input" asp-for="Email" autocomplete="username" aria-required="true" disabled/>
<div>
<span asp-validation-for="Email"></span>
</div>
Expand All @@ -38,7 +38,7 @@
<span asp-validation-for="ConfirmPassword"></span>
</div>
<div class="registration_item">
<input type="submit" value="Save" class="registration_submit registration_button"/>
<input type="submit" value="@SharedLocalizer["Save"]" class="registration_submit registration_button"/>
</div>
</form>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@using Microsoft.AspNetCore.Mvc.Localization
@using OutOfSchool.IdentityServer

@inject IHtmlLocalizer<SharedResource> SharedLocalizer

@{
ViewData["Title"] = SharedLocalizer["Reset password confirmation"];
Layout = "_Layout";
}
<div class="wrapper_body">
<div class="wrapper">
<h2 class="title">@SharedLocalizer["Reset password"]</h2>

<h3 class="instruction">@Model</h3>
</div>
</div>