Handle password reset when SMTP isn't configured or validated (#6150)

* Handle password reset when SMTP isn't configured or the configuration cannot be validated

* include rel in external a tag

* Simplify it

* Test fix

* Simplify a bit

* selenium test to manage users

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Chukwuleta Tobechi
2024-09-13 13:42:08 +01:00
committed by GitHub
parent 7348a6a62f
commit f07ed53f7e
6 changed files with 217 additions and 25 deletions

View File

@@ -403,6 +403,84 @@ namespace BTCPayServer.Tests
Assert.Contains("/login", s.Driver.Url); Assert.Contains("/login", s.Driver.Url);
} }
[Fact(Timeout = TestTimeout)]
public async Task CanManageUsers()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
//Create Users
s.RegisterNewUser();
var user = s.AsTestAccount();
s.Logout();
s.GoToRegister();
s.RegisterNewUser(true);
var admin = s.AsTestAccount();
s.GoToHome();
s.GoToServer(ServerNavPages.Users);
// Manage user password reset
var rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(user.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows);
Assert.Contains(user.RegisterDetails.Email, rows.First().Text);
s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .reset-password")).Click();
s.Driver.WaitForElement(By.Id("Password")).SendKeys("Password@1!");
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("Password@1!");
s.ClickPagePrimary();
Assert.Contains("Password successfully set", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success).Text);
// Manage user status (disable and enable)
// Disable user
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(user.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows);
Assert.Contains(user.RegisterDetails.Email, rows.First().Text);
s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .disable-user")).Click();
s.Driver.WaitForElement(By.Id("ConfirmContinue")).Click();
Assert.Contains("User disabled", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success).Text);
//Enable user
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(user.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows);
Assert.Contains(user.RegisterDetails.Email, rows.First().Text);
s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .enable-user")).Click();
s.Driver.WaitForElement(By.Id("ConfirmContinue")).Click();
Assert.Contains("User enabled", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success).Text);
// Manage user details (edit)
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(user.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows);
Assert.Contains(user.RegisterDetails.Email, rows.First().Text);
s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .user-edit")).Click();
s.Driver.WaitForElement(By.Id("Name")).SendKeys("Test User");
s.ClickPagePrimary();
Assert.Contains("User successfully updated", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success).Text);
// Manage user deletion
s.GoToServer(ServerNavPages.Users);
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(user.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows);
Assert.Contains(user.RegisterDetails.Email, rows.First().Text);
s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .delete-user")).Click();
s.Driver.WaitForElement(By.Id("ConfirmContinue")).Click();
Assert.Contains("User deleted", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success).Text);
s.Driver.AssertNoError();
}
[Fact(Timeout = TestTimeout)] [Fact(Timeout = TestTimeout)]
public async Task CanRequireApprovalForNewAccounts() public async Task CanRequireApprovalForNewAccounts()
{ {

View File

@@ -16,6 +16,7 @@ using BTCPayServer.Filters;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Models.AccountViewModels; using BTCPayServer.Models.AccountViewModels;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using Fido2NetLib; using Fido2NetLib;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -706,7 +707,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/login/forgot-password")] [HttpGet("/login/forgot-password")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult ForgotPassword() public ActionResult ForgotPassword()
{ {
return View(); return View();
} }
@@ -717,7 +718,8 @@ namespace BTCPayServer.Controllers
[RateLimitsFilter(ZoneLimits.ForgotPassword, Scope = RateLimitsScope.RemoteAddress)] [RateLimitsFilter(ZoneLimits.ForgotPassword, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model) public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
{ {
if (ModelState.IsValid) var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>();
if (ModelState.IsValid && settings?.IsComplete() is true)
{ {
var user = await _userManager.FindByEmailAsync(model.Email); var user = await _userManager.FindByEmailAsync(model.Email);
if (!UserService.TryCanLogin(user, out _)) if (!UserService.TryCanLogin(user, out _))
@@ -739,7 +741,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/login/forgot-password/confirm")] [HttpGet("/login/forgot-password/confirm")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult ForgotPasswordConfirmation() public ActionResult ForgotPasswordConfirmation()
{ {
return View(); return View();
} }

View File

@@ -210,6 +210,32 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(User), new { userId }); return RedirectToAction(nameof(User), new { userId });
} }
[HttpGet("server/users/{userId}/reset-password")]
public async Task<IActionResult> ResetUserPassword(string userId)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
return View(new ResetUserPasswordFromAdmin { Email = user.Email });
}
[HttpPost("server/users/{userId}/reset-password")]
public async Task<IActionResult> ResetUserPassword(string userId, ResetUserPasswordFromAdmin model)
{
var user = await _UserManager.FindByEmailAsync(model.Email);
if (user == null || user.Id != userId)
return NotFound();
var result = await _UserManager.ResetPasswordAsync(user, await _UserManager.GeneratePasswordResetTokenAsync(user), model.Password);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = result.Succeeded ? StatusMessageModel.StatusSeverity.Success : StatusMessageModel.StatusSeverity.Error,
Message = result.Succeeded ? "Password successfully set" : "An error occurred while resetting user password"
});
return RedirectToAction(nameof(ListUsers));
}
[HttpGet("server/users/new")] [HttpGet("server/users/new")]
public async Task<IActionResult> CreateUser() public async Task<IActionResult> CreateUser()
{ {
@@ -416,6 +442,24 @@ namespace BTCPayServer.Controllers
} }
} }
public class ResetUserPasswordFromAdmin
{
[Required]
[EmailAddress]
[Display(Name = "Email")]
public string Email { get; set; }
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; }
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
}
public class RegisterFromAdminViewModel public class RegisterFromAdminViewModel
{ {
[Required] [Required]

View File

@@ -1,9 +1,16 @@
@model ForgotPasswordViewModel @using BTCPayServer.Services
@using BTCPayServer.Services.Mails
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model ForgotPasswordViewModel
@inject SettingsRepository SettingsRepository
@{ @{
ViewData["Title"] = "Forgot your password?"; var isEmailConfigured = (await SettingsRepository.GetSettingAsync<EmailSettings>())?.IsComplete() is true;
ViewData["Title"] = isEmailConfigured ? "Forgot your password?" : "Email Server Configuration Required";
Layout = "_LayoutSignedOut"; Layout = "_LayoutSignedOut";
} }
@if (isEmailConfigured)
{
<p> <p>
We all forget passwords every now and then. Just provide email address tied to We all forget passwords every now and then. Just provide email address tied to
your account and we'll start the process of helping you recover your account. your account and we'll start the process of helping you recover your account.
@@ -23,6 +30,17 @@
<button type="submit" class="btn btn-primary btn-lg w-100">Submit</button> <button type="submit" class="btn btn-primary btn-lg w-100">Submit</button>
</div> </div>
</form> </form>
}
else
{
<p>Email password reset functionality is not configured for this server. Please contact the server administrator to assist with account recovery.</p>
<p>
If you are the administrator, please follow these steps to
<a href="https://docs.btcpayserver.org/Notifications/#smtp-email-setup" target="_blank" rel="noreferrer noopener">configure email password resets</a>
or reset your admin password through
<a href="https://docs.btcpayserver.org/FAQ/ServerSettings/#forgot-btcpay-admin-password" target="_blank" rel="noreferrer noopener">command line</a>.
</p>
}
<p class="text-center mt-2 mb-0"> <p class="text-center mt-2 mb-0">
<a id="Login" style="font-size:1.15rem" asp-action="Login" asp-route-returnurl="@ViewData["ReturnUrl"]">Log in</a> <a id="Login" style="font-size:1.15rem" asp-action="Login" asp-route-returnurl="@ViewData["ReturnUrl"]">Log in</a>

View File

@@ -86,9 +86,11 @@
<a asp-action="User" asp-route-userId="@user.Id" class="user-edit">Edit</a> <a asp-action="User" asp-route-userId="@user.Id" class="user-edit">Edit</a>
@if (status.Item2 != "warning") @if (status.Item2 != "warning")
{ {
<a asp-action="ToggleUser" asp-route-userId="@user.Id" asp-route-enable="@user.Disabled">@(user.Disabled ? "Enable" : "Disable")</a> <a asp-action="ToggleUser" asp-route-userId="@user.Id" asp-route-enable="@user.Disabled"
class="@(user.Disabled ? "enable-user" : "disable-user")">@(user.Disabled ? "Enable" : "Disable")</a>
} }
<a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a> <a asp-action="ResetUserPassword" asp-route-userId="@user.Id" class="reset-password">Password Reset</a>
<a asp-action="DeleteUser" asp-route-userId="@user.Id" class="delete-user">Remove</a>
</div> </div>
</td> </td>
<td class="text-end"> <td class="text-end">

View File

@@ -0,0 +1,48 @@
@model BTCPayServer.Controllers.ResetUserPasswordFromAdmin
@{
ViewData.SetActivePage(ServerNavPages.Users, "Reset Password");
}
<form method="post" asp-action="ResetUserPassword">
<div class="sticky-header">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-action="ListUsers">Users</a>
</li>
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
</ol>
<h2 text-translate="true">@ViewData["Title"]</h2>
</nav>
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Reset Password</button>
</div>
<partial name="_StatusMessage" />
<div class="row">
<div class="col-xl-6 col-xxl-constrain">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly"></div>
}
<div class="form-group">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" required="required" class="form-control" readonly />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Password" class="form-label"></label>
<input asp-for="Password" required class="form-control" />
<span asp-validation-for="Password" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ConfirmPassword" class="form-label"></label>
<input asp-for="ConfirmPassword" required class="form-control" />
<span asp-validation-for="ConfirmPassword" class="text-danger"></span>
</div>
</div>
</div>
</form>
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
}