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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 217 additions and 25 deletions

View file

@ -403,6 +403,84 @@ namespace BTCPayServer.Tests
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)]
public async Task CanRequireApprovalForNewAccounts()
{

View file

@ -16,6 +16,7 @@ using BTCPayServer.Filters;
using BTCPayServer.Logging;
using BTCPayServer.Models.AccountViewModels;
using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using Fido2NetLib;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
@ -706,7 +707,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/login/forgot-password")]
[AllowAnonymous]
public IActionResult ForgotPassword()
public ActionResult ForgotPassword()
{
return View();
}
@ -717,7 +718,8 @@ namespace BTCPayServer.Controllers
[RateLimitsFilter(ZoneLimits.ForgotPassword, Scope = RateLimitsScope.RemoteAddress)]
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);
if (!UserService.TryCanLogin(user, out _))
@ -739,7 +741,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/login/forgot-password/confirm")]
[AllowAnonymous]
public IActionResult ForgotPasswordConfirmation()
public ActionResult ForgotPasswordConfirmation()
{
return View();
}

View file

@ -210,6 +210,32 @@ namespace BTCPayServer.Controllers
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")]
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
{
[Required]

View file

@ -1,28 +1,46 @@
@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";
}
<p>
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.
</p>
@if (isEmailConfigured)
{
<p>
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.
</p>
<form asp-action="ForgotPassword" method="post">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
}
<div class="form-group">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group mt-4">
<button type="submit" class="btn btn-primary btn-lg w-100">Submit</button>
</div>
</form>
<form asp-action="ForgotPassword" method="post">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
}
<div class="form-group">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group mt-4">
<button type="submit" class="btn btn-primary btn-lg w-100">Submit</button>
</div>
</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">
<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>
@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>
</td>
<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" />
}