Merge pull request #1886 from Kukks/invite-link

Allow admins to invite new users
This commit is contained in:
Nicolas Dorier 2020-09-19 11:15:38 +09:00 committed by GitHub
commit 62f00fa970
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 531 additions and 300 deletions

View file

@ -12,6 +12,7 @@ using BTCPayServer.Models;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Tests.Logging; using BTCPayServer.Tests.Logging;
using BTCPayServer.Views.Manage; using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores; using BTCPayServer.Views.Stores;
using BTCPayServer.Views.Wallets; using BTCPayServer.Views.Wallets;
using NBitcoin; using NBitcoin;
@ -383,6 +384,16 @@ namespace BTCPayServer.Tests
Driver.FindElement(By.Id($"Wallet{navPages}")).Click(); Driver.FindElement(By.Id($"Wallet{navPages}")).Click();
} }
} }
public void GoToServer(ServerNavPages navPages = ServerNavPages.Index)
{
Driver.FindElement(By.Id("ServerSettings")).Click();
if (navPages != ServerNavPages.Index)
{
Driver.FindElement(By.Id($"Server-{navPages}")).Click();
}
}
public void GoToInvoice(string id) public void GoToInvoice(string id)
{ {

View file

@ -8,6 +8,7 @@ using BTCPayServer.Data;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Wallets;
using BTCPayServer.Tests.Logging; using BTCPayServer.Tests.Logging;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Wallets; using BTCPayServer.Views.Wallets;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NBitcoin; using NBitcoin;
@ -88,7 +89,7 @@ namespace BTCPayServer.Tests
await s.StartAsync(); await s.StartAsync();
//Register & Log Out //Register & Log Out
var email = s.RegisterNewUser(); var email = s.RegisterNewUser();
s.Driver.FindElement(By.Id("Logout")).Click(); s.Logout();
s.Driver.AssertNoError(); s.Driver.AssertNoError();
Assert.Contains("Account/Login", s.Driver.Url); Assert.Contains("Account/Login", s.Driver.Url);
// Should show the Tor address // Should show the Tor address
@ -129,7 +130,32 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("MySettings")).Click(); s.Driver.FindElement(By.Id("MySettings")).Click();
s.ClickOnAllSideMenus(); s.ClickOnAllSideMenus();
s.Driver.Quit(); //let's test invite link
s.Logout();
s.GoToRegister();
var newAdminUser = s.RegisterNewUser(true);
s.GoToServer(ServerNavPages.Users);
s.Driver.FindElement(By.Id("CreateUser")).Click();
var usr = RandomUtils.GetUInt256().ToString().Substring(64 - 20) + "@a.com";
s.Driver.FindElement(By.Id("Email")).SendKeys(usr);
s.Driver.FindElement(By.Id("Save")).Click();
var url = s.AssertHappyMessage().FindElement(By.TagName("a")).Text;;
s.Logout();
s.Driver.Navigate().GoToUrl(url);
Assert.Equal("hidden",s.Driver.FindElement(By.Id("Email")).GetAttribute("type"));
Assert.Equal(usr,s.Driver.FindElement(By.Id("Email")).GetAttribute("value"));
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456");
s.Driver.FindElement(By.Id("SetPassword")).Click();
s.AssertHappyMessage();
s.Driver.FindElement(By.Id("Email")).SendKeys(usr);
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
s.Driver.FindElement(By.Id("LoginButton")).Click();
// We should be logged in now
s.Driver.FindElement(By.Id("mainNav"));
} }
} }

View file

@ -8,8 +8,6 @@ using BTCPayServer.Models;
using BTCPayServer.Models.AccountViewModels; using BTCPayServer.Models.AccountViewModels;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores;
using BTCPayServer.U2F; using BTCPayServer.U2F;
using BTCPayServer.U2F.Models; using BTCPayServer.U2F.Models;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
@ -28,8 +26,6 @@ namespace BTCPayServer.Controllers
{ {
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager; private readonly SignInManager<ApplicationUser> _signInManager;
private readonly EmailSenderFactory _EmailSenderFactory;
readonly StoreRepository storeRepository;
readonly RoleManager<IdentityRole> _RoleManager; readonly RoleManager<IdentityRole> _RoleManager;
readonly SettingsRepository _SettingsRepository; readonly SettingsRepository _SettingsRepository;
readonly Configuration.BTCPayServerOptions _Options; readonly Configuration.BTCPayServerOptions _Options;
@ -41,19 +37,15 @@ namespace BTCPayServer.Controllers
public AccountController( public AccountController(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager, RoleManager<IdentityRole> roleManager,
StoreRepository storeRepository,
SignInManager<ApplicationUser> signInManager, SignInManager<ApplicationUser> signInManager,
EmailSenderFactory emailSenderFactory,
SettingsRepository settingsRepository, SettingsRepository settingsRepository,
Configuration.BTCPayServerOptions options, Configuration.BTCPayServerOptions options,
BTCPayServerEnvironment btcPayServerEnvironment, BTCPayServerEnvironment btcPayServerEnvironment,
U2FService u2FService, U2FService u2FService,
EventAggregator eventAggregator) EventAggregator eventAggregator)
{ {
this.storeRepository = storeRepository;
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
_EmailSenderFactory = emailSenderFactory;
_RoleManager = roleManager; _RoleManager = roleManager;
_SettingsRepository = settingsRepository; _SettingsRepository = settingsRepository;
_Options = options; _Options = options;
@ -71,7 +63,7 @@ namespace BTCPayServer.Controllers
[HttpGet] [HttpGet]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> Login(string returnUrl = null) public async Task<IActionResult> Login(string returnUrl = null, string email = null)
{ {
if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl)) if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl))
@ -85,7 +77,10 @@ namespace BTCPayServer.Controllers
} }
ViewData["ReturnUrl"] = returnUrl; ViewData["ReturnUrl"] = returnUrl;
return View(); return View(new LoginViewModel()
{
Email = email
});
} }
@ -500,8 +495,30 @@ namespace BTCPayServer.Controllers
{ {
throw new ApplicationException($"Unable to load user with ID '{userId}'."); throw new ApplicationException($"Unable to load user with ID '{userId}'.");
} }
var result = await _userManager.ConfirmEmailAsync(user, code); var result = await _userManager.ConfirmEmailAsync(user, code);
return View(result.Succeeded ? "ConfirmEmail" : "Error"); if (!await _userManager.HasPasswordAsync(user))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Info,
Message = "Your email has been confirmed but you still need to set your password."
});
return RedirectToAction("SetPassword", new { email = user.Email, code= await _userManager.GeneratePasswordResetTokenAsync(user)});
}
if (result.Succeeded)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Your email has been confirmed."
});
return RedirectToAction("Login", new {email = user.Email});
}
return View("Error");
} }
[HttpGet] [HttpGet]
@ -524,14 +541,10 @@ namespace BTCPayServer.Controllers
// Don't reveal that the user does not exist or is not confirmed // Don't reveal that the user does not exist or is not confirmed
return RedirectToAction(nameof(ForgotPasswordConfirmation)); return RedirectToAction(nameof(ForgotPasswordConfirmation));
} }
_eventAggregator.Publish(new UserPasswordResetRequestedEvent()
// For more information on how to enable account confirmation and password reset please {
// visit https://go.microsoft.com/fwlink/?LinkID=532713 User = user, RequestUri = Request.GetAbsoluteRootUri()
var code = await _userManager.GeneratePasswordResetTokenAsync(user); });
var callbackUrl = Url.ResetPasswordCallbackLink(user.Id, code, Request.Scheme);
_EmailSenderFactory.GetEmailSender().SendEmail(model.Email, "Reset Password",
$"Please reset your password by clicking here: <a href='{callbackUrl}'>link</a>");
return RedirectToAction(nameof(ForgotPasswordConfirmation)); return RedirectToAction(nameof(ForgotPasswordConfirmation));
} }
@ -548,20 +561,27 @@ namespace BTCPayServer.Controllers
[HttpGet] [HttpGet]
[AllowAnonymous] [AllowAnonymous]
public IActionResult ResetPassword(string code = null) public async Task<IActionResult> SetPassword(string code = null, string userId = null, string email = null)
{ {
if (code == null) if (code == null)
{ {
throw new ApplicationException("A code must be supplied for password reset."); throw new ApplicationException("A code must be supplied for password reset.");
} }
var model = new ResetPasswordViewModel { Code = code };
if (!string.IsNullOrEmpty(userId))
{
var user = await _userManager.FindByIdAsync(userId);
email = user?.Email;
}
var model = new SetPasswordViewModel {Code = code, Email = email, EmailSetInternally = !string.IsNullOrEmpty(email)};
return View(model); return View(model);
} }
[HttpPost] [HttpPost]
[AllowAnonymous] [AllowAnonymous]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model) public async Task<IActionResult> SetPassword(SetPasswordViewModel model)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
@ -571,25 +591,23 @@ namespace BTCPayServer.Controllers
if (user == null) if (user == null)
{ {
// Don't reveal that the user does not exist // Don't reveal that the user does not exist
return RedirectToAction(nameof(ResetPasswordConfirmation)); return RedirectToAction(nameof(Login));
} }
var result = await _userManager.ResetPasswordAsync(user, model.Code, model.Password); var result = await _userManager.ResetPasswordAsync(user, model.Code, model.Password);
if (result.Succeeded) if (result.Succeeded)
{ {
return RedirectToAction(nameof(ResetPasswordConfirmation)); TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success, Message = "Password successfully set."
});
return RedirectToAction(nameof(Login));
} }
AddErrors(result); AddErrors(result);
return View(); return View();
} }
[HttpGet]
[AllowAnonymous]
public IActionResult ResetPasswordConfirmation()
{
return View();
}
[HttpGet] [HttpGet]
public IActionResult AccessDenied() public IActionResult AccessDenied()
{ {

View file

@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Storage.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
public partial class ServerController
{
[Route("server/users")]
public IActionResult ListUsers(int skip = 0, int count = 50)
{
var users = new UsersViewModel();
users.Users = _UserManager.Users.Skip(skip).Take(count)
.Select(u => new UsersViewModel.UserViewModel
{
Name = u.UserName,
Email = u.Email,
Id = u.Id
}).ToList();
users.Skip = skip;
users.Count = count;
users.Total = _UserManager.Users.Count();
return View(users);
}
[Route("server/users/{userId}")]
public new async Task<IActionResult> User(string userId)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var roles = await _UserManager.GetRolesAsync(user);
var userVM = new UserViewModel();
userVM.Id = user.Id;
userVM.Email = user.Email;
userVM.IsAdmin = IsAdmin(roles);
return View(userVM);
}
private static bool IsAdmin(IList<string> roles)
{
return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal);
}
[Route("server/users/{userId}")]
[HttpPost]
public new async Task<IActionResult> User(string userId, UserViewModel viewModel)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var roles = await _UserManager.GetRolesAsync(user);
var wasAdmin = IsAdmin(roles);
if (!viewModel.IsAdmin && admins.Count == 1 && wasAdmin)
{
TempData[WellKnownTempData.ErrorMessage] = "This is the only Admin, so their role can't be removed until another Admin is added.";
return View(viewModel); // return
}
if (viewModel.IsAdmin != wasAdmin)
{
if (viewModel.IsAdmin)
await _UserManager.AddToRoleAsync(user, Roles.ServerAdmin);
else
await _UserManager.RemoveFromRoleAsync(user, Roles.ServerAdmin);
TempData[WellKnownTempData.SuccessMessage] = "User successfully updated";
}
return RedirectToAction(nameof(User), new { userId = userId });
}
[Route("server/users/new")]
[HttpGet]
public IActionResult CreateUser()
{
ViewData["AllowIsAdmin"] = _Options.AllowAdminRegistration;
ViewData["AllowRequestEmailConfirmation"] = _cssThemeManager.Policies.RequiresConfirmedEmail;
return View();
}
[Route("server/users/new")]
[HttpPost]
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
{
ViewData["AllowIsAdmin"] = _Options.AllowAdminRegistration;
ViewData["AllowRequestEmailConfirmation"] = _cssThemeManager.Policies.RequiresConfirmedEmail;
if (ModelState.IsValid)
{
IdentityResult result;
var user = new ApplicationUser { UserName = model.Email, Email = model.Email, EmailConfirmed = model.EmailConfirmed, RequiresEmailConfirmation = _cssThemeManager.Policies.RequiresConfirmedEmail };
if (!string.IsNullOrEmpty(model.Password))
{
result = await _UserManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
TempData[WellKnownTempData.SuccessMessage] = "Account created";
return RedirectToAction(nameof(ListUsers));
}
}
else
{
result = await _UserManager.CreateAsync(user);
}
if (result.Succeeded)
{
if (model.IsAdmin && !(await _UserManager.AddToRoleAsync(user, Roles.ServerAdmin)).Succeeded)
model.IsAdmin = false;
var tcs = new TaskCompletionSource<Uri>();
_eventAggregator.Publish(new UserRegisteredEvent()
{
RequestUri = Request.GetAbsoluteRootUri(), User = user, Admin = model.IsAdmin is true, CallbackUrlGenerated = tcs
});
var callbackUrl = await tcs.Task;
if (user.RequiresEmailConfirmation && !user.EmailConfirmed)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false,
Html =
$"Account created without a set password. An email will be sent (if configured) to set the password.<br/> You may alternatively share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
});
}else if (!await _UserManager.HasPasswordAsync(user))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false,
Html =
$"Account created without a set password. An email will be sent (if configured) to set the password.<br/> You may alternatively share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
});
}
return RedirectToAction(nameof(ListUsers));
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
// If we got this far, something failed, redisplay form
return View(model);
}
[Route("server/users/{userId}/delete")]
public async Task<IActionResult> DeleteUser(string userId)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var roles = await _UserManager.GetRolesAsync(user);
if (IsAdmin(roles))
{
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
if (admins.Count == 1)
{
// return
return View("Confirm", new ConfirmModel("Unable to Delete Last Admin",
"This is the last Admin, so it can't be removed"));
}
return View("Confirm", new ConfirmModel("Delete Admin " + user.Email,
"Are you sure you want to delete this Admin and delete all accounts, users and data associated with the server account?",
"Delete"));
}
else
{
return View("Confirm", new ConfirmModel("Delete user " + user.Email,
"This user will be permanently deleted",
"Delete"));
}
}
[Route("server/users/{userId}/delete")]
[HttpPost]
public async Task<IActionResult> DeleteUserPost(string userId)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var files = await _StoredFileRepository.GetFiles(new StoredFileRepository.FilesQuery()
{
UserIds = new[] { userId },
});
await Task.WhenAll(files.Select(file => _FileService.RemoveFile(file.Id, userId)));
await _UserManager.DeleteAsync(user);
await _StoreRepository.CleanUnreachableStores();
TempData[WellKnownTempData.SuccessMessage] = "User deleted";
return RedirectToAction(nameof(ListUsers));
}
}
public class RegisterFromAdminViewModel
{
[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 (leave blank to generate invite-link)")]
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; }
[Display(Name = "Is administrator?")]
public bool IsAdmin { get; set; }
[Display(Name = "Email confirmed?")]
public bool EmailConfirmed { get; set; }
}
}

View file

@ -26,6 +26,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NBitcoin; using NBitcoin;
@ -47,6 +48,8 @@ namespace BTCPayServer.Controllers
private readonly BTCPayServerOptions _Options; private readonly BTCPayServerOptions _Options;
private readonly AppService _AppService; private readonly AppService _AppService;
private readonly CheckConfigurationHostedService _sshState; private readonly CheckConfigurationHostedService _sshState;
private readonly EventAggregator _eventAggregator;
private readonly CssThemeManager _cssThemeManager;
private readonly StoredFileRepository _StoredFileRepository; private readonly StoredFileRepository _StoredFileRepository;
private readonly FileService _FileService; private readonly FileService _FileService;
private readonly IEnumerable<IStorageProviderService> _StorageProviderServices; private readonly IEnumerable<IStorageProviderService> _StorageProviderServices;
@ -63,7 +66,9 @@ namespace BTCPayServer.Controllers
TorServices torServices, TorServices torServices,
StoreRepository storeRepository, StoreRepository storeRepository,
AppService appService, AppService appService,
CheckConfigurationHostedService sshState) CheckConfigurationHostedService sshState,
EventAggregator eventAggregator,
CssThemeManager cssThemeManager)
{ {
_Options = options; _Options = options;
_StoredFileRepository = storedFileRepository; _StoredFileRepository = storedFileRepository;
@ -78,37 +83,8 @@ namespace BTCPayServer.Controllers
_torServices = torServices; _torServices = torServices;
_AppService = appService; _AppService = appService;
_sshState = sshState; _sshState = sshState;
} _eventAggregator = eventAggregator;
_cssThemeManager = cssThemeManager;
[Route("server/users")]
public IActionResult ListUsers(int skip = 0, int count = 50)
{
var users = new UsersViewModel();
users.Users = _UserManager.Users.Skip(skip).Take(count)
.Select(u => new UsersViewModel.UserViewModel
{
Name = u.UserName,
Email = u.Email,
Id = u.Id
}).ToList();
users.Skip = skip;
users.Count = count;
users.Total = _UserManager.Users.Count();
return View(users);
}
[Route("server/users/{userId}")]
public new async Task<IActionResult> User(string userId)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var roles = await _UserManager.GetRolesAsync(user);
var userVM = new UserViewModel();
userVM.Id = user.Id;
userVM.Email = user.Email;
userVM.IsAdmin = IsAdmin(roles);
return View(userVM);
} }
[Route("server/maintenance")] [Route("server/maintenance")]
@ -270,127 +246,7 @@ namespace BTCPayServer.Controllers
sshClient.Dispose(); sshClient.Dispose();
} }
} }
private static bool IsAdmin(IList<string> roles)
{
return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal);
}
[Route("server/users/{userId}")]
[HttpPost]
public new async Task<IActionResult> User(string userId, UserViewModel viewModel)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var roles = await _UserManager.GetRolesAsync(user);
var wasAdmin = IsAdmin(roles);
if (!viewModel.IsAdmin && admins.Count == 1 && wasAdmin)
{
TempData[WellKnownTempData.ErrorMessage] = "This is the only Admin, so their role can't be removed until another Admin is added.";
return View(viewModel); // return
}
if (viewModel.IsAdmin != wasAdmin)
{
if (viewModel.IsAdmin)
await _UserManager.AddToRoleAsync(user, Roles.ServerAdmin);
else
await _UserManager.RemoveFromRoleAsync(user, Roles.ServerAdmin);
TempData[WellKnownTempData.SuccessMessage] = "User successfully updated";
}
return RedirectToAction(nameof(User), new { userId = userId });
}
[Route("server/users/new")]
[HttpGet]
public IActionResult CreateUser()
{
ViewData["AllowIsAdmin"] = _Options.AllowAdminRegistration;
return View();
}
[Route("server/users/new")]
[HttpPost]
public async Task<IActionResult> CreateUser(RegisterViewModel model)
{
ViewData["AllowIsAdmin"] = _Options.AllowAdminRegistration;
if (ModelState.IsValid)
{
var user = new ApplicationUser { UserName = model.Email, Email = model.Email, RequiresEmailConfirmation = false };
var result = await _UserManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
TempData[WellKnownTempData.SuccessMessage] = "Account created";
return RedirectToAction(nameof(ListUsers));
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
// If we got this far, something failed, redisplay form
return View(model);
}
[Route("server/users/{userId}/delete")]
public async Task<IActionResult> DeleteUser(string userId)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var roles = await _UserManager.GetRolesAsync(user);
if (IsAdmin(roles))
{
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
if (admins.Count == 1)
{
// return
return View("Confirm", new ConfirmModel("Unable to Delete Last Admin",
"This is the last Admin, so it can't be removed"));
}
return View("Confirm", new ConfirmModel("Delete Admin " + user.Email,
"Are you sure you want to delete this Admin and delete all accounts, users and data associated with the server account?",
"Delete"));
}
else
{
return View("Confirm", new ConfirmModel("Delete user " + user.Email,
"This user will be permanently deleted",
"Delete"));
}
}
[Route("server/users/{userId}/delete")]
[HttpPost]
public async Task<IActionResult> DeleteUserPost(string userId)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var files = await _StoredFileRepository.GetFiles(new StoredFileRepository.FilesQuery()
{
UserIds = new[] { userId },
});
await Task.WhenAll(files.Select(file => _FileService.RemoveFile(file.Id, userId)));
await _UserManager.DeleteAsync(user);
await _StoreRepository.CleanUnreachableStores();
TempData[WellKnownTempData.SuccessMessage] = "User deleted";
return RedirectToAction(nameof(ListUsers));
}
public IHttpClientFactory HttpClientFactory { get; } public IHttpClientFactory HttpClientFactory { get; }
[Route("server/policies")] [Route("server/policies")]

View file

@ -0,0 +1,13 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Data;
namespace BTCPayServer.Events
{
public class UserPasswordResetRequestedEvent
{
public ApplicationUser User { get; set; }
public Uri RequestUri { get; set; }
public TaskCompletionSource<Uri> CallbackUrlGenerated;
}
}

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Threading.Tasks;
using BTCPayServer.Data; using BTCPayServer.Data;
namespace BTCPayServer.Events namespace BTCPayServer.Events
@ -8,5 +9,7 @@ namespace BTCPayServer.Events
public ApplicationUser User { get; set; } public ApplicationUser User { get; set; }
public bool Admin { get; set; } public bool Admin { get; set; }
public Uri RequestUri { get; set; } public Uri RequestUri { get; set; }
public TaskCompletionSource<Uri> CallbackUrlGenerated;
} }
} }

View file

@ -10,5 +10,12 @@ namespace BTCPayServer.Services
emailSender.SendEmail(email, "Confirm your email", emailSender.SendEmail(email, "Confirm your email",
$"Please confirm your account by clicking this link: <a href='{HtmlEncoder.Default.Encode(link)}'>link</a>"); $"Please confirm your account by clicking this link: <a href='{HtmlEncoder.Default.Encode(link)}'>link</a>");
} }
public static void SendSetPasswordConfirmation(this IEmailSender emailSender, string email, string link, bool newPassword)
{
emailSender.SendEmail(email,
$"{(newPassword ? "Set" : "Reset")} Password",
$"Please {(newPassword ? "set" : "reset")} your password by clicking here: <a href='{link}'>link</a>");
}
} }
} }

View file

@ -15,13 +15,16 @@ namespace Microsoft.AspNetCore.Mvc
new { userId, code }, scheme, host, pathbase); new { userId, code }, scheme, host, pathbase);
} }
public static string ResetPasswordCallbackLink(this IUrlHelper urlHelper, string userId, string code, string scheme) public static string ResetPasswordCallbackLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
{ {
return urlHelper.Action( return urlHelper.GetUriByAction(
action: nameof(AccountController.ResetPassword), action: nameof(AccountController.SetPassword),
controller: "Account", controller: "Account",
values: new { userId, code }, values: new { userId, code },
protocol: scheme); scheme: scheme,
host:host,
pathBase: pathbase
);
} }
public static string PaymentRequestLink(this LinkGenerator urlHelper, string paymentRequestId, string scheme, HostString host, string pathbase) public static string PaymentRequestLink(this LinkGenerator urlHelper, string paymentRequestId, string scheme, HostString host, string pathbase)

View file

@ -54,34 +54,32 @@ namespace BTCPayServer.HostedServices
} }
private string _creativeStartUri; private string _creativeStartUri;
private PoliciesSettings _policies = new PoliciesSettings();
public PoliciesSettings Policies { get { return _policies; } }
public string CreativeStartUri public string CreativeStartUri
{ {
get { return _creativeStartUri; } get { return _creativeStartUri; }
} }
public bool ShowRegister { get; set; } public bool ShowRegister { get { return !_policies.LockSubscription; } }
public bool DiscourageSearchEngines { get; set; } public bool DiscourageSearchEngines { get { return _policies.DiscourageSearchEngines; } }
public AppType? RootAppType { get { return _policies.RootAppType; } }
public AppType? RootAppType { get; set; } public string RootAppId { get { return _policies.RootAppId; } }
public string RootAppId { get; set; }
public bool FirstRun { get; set; } public bool FirstRun { get; set; }
public List<PoliciesSettings.DomainToAppMappingItem> DomainToAppMapping { get; set; } = new List<PoliciesSettings.DomainToAppMappingItem>(); public List<PoliciesSettings.DomainToAppMappingItem> DomainToAppMapping { get { return _policies.DomainToAppMapping; } }
internal void Update(PoliciesSettings data) internal void Update(PoliciesSettings data)
{ {
ShowRegister = !data.LockSubscription; _policies = data;
DiscourageSearchEngines = data.DiscourageSearchEngines;
RootAppType = data.RootAppType;
RootAppId = data.RootAppId;
DomainToAppMapping = data.DomainToAppMapping;
AllowLightningInternalNodeForAll = data.AllowLightningInternalNodeForAll;
} }
public bool AllowLightningInternalNodeForAll { get; set; } public bool AllowLightningInternalNodeForAll { get { return _policies.AllowLightningInternalNodeForAll; } }
} }
public class ContentSecurityPolicyCssThemeManager : Attribute, IActionFilter, IOrderedFilter public class ContentSecurityPolicyCssThemeManager : Attribute, IActionFilter, IOrderedFilter

View file

@ -1,3 +1,4 @@
using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data; using BTCPayServer.Data;
@ -30,23 +31,60 @@ namespace BTCPayServer.HostedServices
protected override void SubscribeToEvents() protected override void SubscribeToEvents()
{ {
Subscribe<UserRegisteredEvent>(); Subscribe<UserRegisteredEvent>();
Subscribe<UserPasswordResetRequestedEvent>();
} }
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{ {
string code;
string callbackUrl;
UserPasswordResetRequestedEvent userPasswordResetRequestedEvent;
switch (evt) switch (evt)
{ {
case UserRegisteredEvent userRegisteredEvent: case UserRegisteredEvent userRegisteredEvent:
Logs.PayServer.LogInformation($"A new user just registered {userRegisteredEvent.User.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}"); Logs.PayServer.LogInformation(
$"A new user just registered {userRegisteredEvent.User.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}");
if (!userRegisteredEvent.User.EmailConfirmed && userRegisteredEvent.User.RequiresEmailConfirmation) if (!userRegisteredEvent.User.EmailConfirmed && userRegisteredEvent.User.RequiresEmailConfirmation)
{ {
code = await _userManager.GenerateEmailConfirmationTokenAsync(userRegisteredEvent.User);
var code = await _userManager.GenerateEmailConfirmationTokenAsync(userRegisteredEvent.User); callbackUrl = _generator.EmailConfirmationLink(userRegisteredEvent.User.Id, code,
var callbackUrl = _generator.EmailConfirmationLink(userRegisteredEvent.User.Id, code, userRegisteredEvent.RequestUri.Scheme, new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port), userRegisteredEvent.RequestUri.PathAndQuery); userRegisteredEvent.RequestUri.Scheme,
new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port),
userRegisteredEvent.RequestUri.PathAndQuery);
userRegisteredEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
_emailSenderFactory.GetEmailSender() _emailSenderFactory.GetEmailSender()
.SendEmailConfirmation(userRegisteredEvent.User.Email, callbackUrl); .SendEmailConfirmation(userRegisteredEvent.User.Email, callbackUrl);
} }
else if (!await _userManager.HasPasswordAsync(userRegisteredEvent.User))
{
userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent()
{
CallbackUrlGenerated = userRegisteredEvent.CallbackUrlGenerated,
User = userRegisteredEvent.User,
RequestUri = userRegisteredEvent.RequestUri
};
goto passwordSetter;
}
else
{
userRegisteredEvent.CallbackUrlGenerated?.SetResult(null);
}
break;
case UserPasswordResetRequestedEvent userPasswordResetRequestedEvent2:
userPasswordResetRequestedEvent = userPasswordResetRequestedEvent2;
passwordSetter:
code = await _userManager.GeneratePasswordResetTokenAsync(userPasswordResetRequestedEvent.User);
var newPassword = await _userManager.HasPasswordAsync(userPasswordResetRequestedEvent.User);
callbackUrl = _generator.ResetPasswordCallbackLink(userPasswordResetRequestedEvent.User.Id, code,
userPasswordResetRequestedEvent.RequestUri.Scheme,
new HostString(userPasswordResetRequestedEvent.RequestUri.Host,
userPasswordResetRequestedEvent.RequestUri.Port),
userPasswordResetRequestedEvent.RequestUri.PathAndQuery);
userPasswordResetRequestedEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
_emailSenderFactory.GetEmailSender()
.SendSetPasswordConfirmation(userPasswordResetRequestedEvent.User.Email, callbackUrl,
newPassword);
break; break;
} }
} }

View file

@ -2,12 +2,11 @@ using System.ComponentModel.DataAnnotations;
namespace BTCPayServer.Models.AccountViewModels namespace BTCPayServer.Models.AccountViewModels
{ {
public class ResetPasswordViewModel public class SetPasswordViewModel
{ {
[Required] [Required]
[EmailAddress] [EmailAddress]
public string Email { get; set; } public string Email { get; set; }
[Required] [Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)] [DataType(DataType.Password)]
@ -19,5 +18,6 @@ namespace BTCPayServer.Models.AccountViewModels
public string ConfirmPassword { get; set; } public string ConfirmPassword { get; set; }
public string Code { get; set; } public string Code { get; set; }
public bool EmailSetInternally { get; set; }
} }
} }

View file

@ -1,14 +0,0 @@
@{
ViewData["Title"] = "Confirm email";
}
<section>
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
<partial name="_StatusMessage" model="@("Thank you for confirming your email.")" />
</div>
</div>
</div>
</section>

View file

@ -1,45 +0,0 @@
@model ResetPasswordViewModel
@{
ViewData["Title"] = "Reset password";
}
<section>
<div class="container">
@if (TempData.HasStatusMessage())
{
<div class="row">
<div class="col-lg-12 text-center">
<partial name="_StatusMessage" />
</div>
</div>
}
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="All" class="text-danger"></div>
<input asp-for="Code" type="hidden" />
<div class="form-group">
<label asp-for="Email"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Password"></label>
<input asp-for="Password" class="form-control" />
<span asp-validation-for="Password" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ConfirmPassword"></label>
<input asp-for="ConfirmPassword" class="form-control" />
<span asp-validation-for="ConfirmPassword" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary">Reset</button>
</form>
</div>
</div>
</div>
</section>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View file

@ -1,14 +0,0 @@
@{
ViewData["Title"] = "Reset password confirmation";
}
<h2>@ViewData["Title"]</h2>
<section>
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
Your password has been reset. Please <a asp-action="Login">click here to log in</a>.
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,76 @@
@model BTCPayServer.Models.AccountViewModels.SetPasswordViewModel
@{
ViewData["Title"] = "Reset password";
Layout = "_LayoutSimple";
}
<div class="row justify-content-center mb-2">
<div class="col text-center">
<a asp-controller="Home" asp-action="Index">
<img src="~/img/btcpay-logo.svg" alt="BTCPay Server" class="mb-4" height="70" asp-append-version="true"/>
</a>
<h1 class="h2 mb-3">Welcome to your BTCPay&nbsp;Server</h1>
@if (TempData.HasStatusMessage())
{
<partial name="_StatusMessage"/>
}
</div>
</div>
<div class="row justify-content-center mb-5">
<div class="col account-form">
<div class="modal-content border-0 p-3">
<div class="modal-header align-items-center border-0 py-2">
<h4 class="modal-title">Set Password</h4>
</div>
<div class="modal-body">
<form method="post" asp-action="SetPassword">
<div asp-validation-summary="All" class="text-danger"></div>
<input asp-for="Code" type="hidden"/>
<input asp-for="EmailSetInternally" type="hidden"/>
@if (Model.EmailSetInternally)
{
<input asp-for="Email" type="hidden"/>
<div class="form-group">
<label asp-for="Email"></label>
<input type="text" disabled value="@Model.Email" class="form-control"/>
<span asp-validation-for="Email" class="text-danger"></span>
</div>
}
else
{
<div class="form-group">
<label asp-for="Email"></label>
<input asp-for="Email" value="@Model.Email" class="form-control"/>
<span asp-validation-for="Email" class="text-danger"></span>
</div>
}
<div class="form-group">
<label asp-for="Password"></label>
<input asp-for="Password" class="form-control"/>
<span asp-validation-for="Password" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ConfirmPassword"></label>
<input asp-for="ConfirmPassword" class="form-control"/>
<span asp-validation-for="ConfirmPassword" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg" id="SetPassword">Set password</button>
</form>
</div>
</div>
</div>
</div>
<div class="row justify-content-center mt-5">
<div class="col">
<partial name="_BTCPaySupporters"/>
</div>
</div>

View file

@ -1,4 +1,4 @@
@model SetPasswordViewModel @model BTCPayServer.Models.ManageViewModels.SetPasswordViewModel
@{ @{
ViewData.SetActivePageAndTitle(ManageNavPages.ChangePassword, "Set password"); ViewData.SetActivePageAndTitle(ManageNavPages.ChangePassword, "Set password");
} }

View file

@ -1,4 +1,4 @@
@model RegisterViewModel @model BTCPayServer.Controllers.RegisterFromAdminViewModel
@{ @{
ViewData.SetActivePageAndTitle(ServerNavPages.Users, $"Users - Create account"); ViewData.SetActivePageAndTitle(ServerNavPages.Users, $"Users - Create account");
} }
@ -18,13 +18,13 @@
<div class="form-group"> <div class="form-group">
<label asp-for="Password"></label> <label asp-for="Password"></label>
<input asp-for="Password" required="required" class="form-control"/> <input asp-for="Password" class="form-control"/>
<span asp-validation-for="Password" class="text-danger"></span> <span asp-validation-for="Password" class="text-danger"></span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label asp-for="ConfirmPassword"></label> <label asp-for="ConfirmPassword"></label>
<input asp-for="ConfirmPassword" required="required" class="form-control"/> <input asp-for="ConfirmPassword" class="form-control"/>
<span asp-validation-for="ConfirmPassword" class="text-danger"></span> <span asp-validation-for="ConfirmPassword" class="text-danger"></span>
</div> </div>
@ -36,8 +36,18 @@
<span asp-validation-for="IsAdmin" class="text-danger"></span> <span asp-validation-for="IsAdmin" class="text-danger"></span>
</div> </div>
} }
<button type="submit" class="btn btn-primary" name="command" value="Save">Create account</button> @if (ViewData["AllowRequestEmailConfirmation"] is true)
{
<div class="form-group form-check">
<input asp-for="EmailConfirmed" type="checkbox" class="form-check-input"/>
<label asp-for="EmailConfirmed" class="form-check-label"></label>
<span asp-validation-for="EmailConfirmed" class="text-danger"></span>
</div>
}
<button id="Save" type="submit" class="btn btn-primary" name="command" value="Save">Create account</button>
</form> </form>
</div> </div>
</div> </div>

View file

@ -9,7 +9,7 @@
<div class="col-lg-9 col-xl-8"> <div class="col-lg-9 col-xl-8">
<span>Total Users: @Model.Total</span> <span>Total Users: @Model.Total</span>
<span class="pull-right"> <span class="pull-right">
<a asp-action="CreateUser" class="btn btn-primary" role="button"> <a asp-action="CreateUser" class="btn btn-primary" role="button" id="CreateUser">
<span class="fa fa-plus"></span> Add User <span class="fa fa-plus"></span> Add User
</a> </a>
</span> </span>

View file

@ -1,10 +1,10 @@
<div class="nav flex-column nav-pills mb-4"> <div class="nav flex-column nav-pills mb-4">
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Users)" asp-action="ListUsers">Users</a> <a asp-controller="Server" id="Server-@ServerNavPages.Users" class="nav-link @ViewData.IsActivePage(ServerNavPages.Users)" asp-action="ListUsers">Users</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Emails)" asp-action="Emails">Email server</a> <a asp-controller="Server" id="Server-@ServerNavPages.Emails" class="nav-link @ViewData.IsActivePage(ServerNavPages.Emails)" asp-action="Emails">Email server</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Policies)" asp-action="Policies">Policies</a> <a asp-controller="Server" id="Server-@ServerNavPages.Policies" class="nav-link @ViewData.IsActivePage(ServerNavPages.Policies)" asp-action="Policies">Policies</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Services)" asp-action="Services">Services</a> <a asp-controller="Server" id="Server-@ServerNavPages.Services" class="nav-link @ViewData.IsActivePage(ServerNavPages.Services)" asp-action="Services">Services</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Theme)" asp-action="Theme">Theme</a> <a asp-controller="Server" id="Server-@ServerNavPages.Theme" class="nav-link @ViewData.IsActivePage(ServerNavPages.Theme)" asp-action="Theme">Theme</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Maintenance)" asp-action="Maintenance">Maintenance</a> <a asp-controller="Server" id="Server-@ServerNavPages.Maintenance" class="nav-link @ViewData.IsActivePage(ServerNavPages.Maintenance)" asp-action="Maintenance">Maintenance</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Logs)" asp-action="LogsView">Logs</a> <a asp-controller="Server" id="Server-@ServerNavPages.Logs" class="nav-link @ViewData.IsActivePage(ServerNavPages.Logs)" asp-action="LogsView">Logs</a>
<a asp-controller="Server" class="nav-link @ViewData.IsActivePage(ServerNavPages.Files)" asp-action="Files">Files</a> <a asp-controller="Server" id="Server-@ServerNavPages.Files" class="nav-link @ViewData.IsActivePage(ServerNavPages.Files)" asp-action="Files">Files</a>
</div> </div>