diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 4216f9841..96c853b68 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -12,6 +12,7 @@ using BTCPayServer.Models; using BTCPayServer.Services; using BTCPayServer.Tests.Logging; using BTCPayServer.Views.Manage; +using BTCPayServer.Views.Server; using BTCPayServer.Views.Stores; using BTCPayServer.Views.Wallets; using NBitcoin; @@ -383,6 +384,16 @@ namespace BTCPayServer.Tests 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) { diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 3b46d384a..531200616 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -8,6 +8,7 @@ using BTCPayServer.Data; using BTCPayServer.Models; using BTCPayServer.Services.Wallets; using BTCPayServer.Tests.Logging; +using BTCPayServer.Views.Server; using BTCPayServer.Views.Wallets; using Microsoft.EntityFrameworkCore; using NBitcoin; @@ -88,7 +89,7 @@ namespace BTCPayServer.Tests await s.StartAsync(); //Register & Log Out var email = s.RegisterNewUser(); - s.Driver.FindElement(By.Id("Logout")).Click(); + s.Logout(); s.Driver.AssertNoError(); Assert.Contains("Account/Login", s.Driver.Url); // Should show the Tor address @@ -129,7 +130,32 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.Id("MySettings")).Click(); 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")); } } diff --git a/BTCPayServer/Controllers/AccountController.cs b/BTCPayServer/Controllers/AccountController.cs index 6d1b8d3c0..ae5520317 100644 --- a/BTCPayServer/Controllers/AccountController.cs +++ b/BTCPayServer/Controllers/AccountController.cs @@ -8,8 +8,6 @@ using BTCPayServer.Models; using BTCPayServer.Models.AccountViewModels; using BTCPayServer.Security; using BTCPayServer.Services; -using BTCPayServer.Services.Mails; -using BTCPayServer.Services.Stores; using BTCPayServer.U2F; using BTCPayServer.U2F.Models; using Microsoft.AspNetCore.Authentication; @@ -28,8 +26,6 @@ namespace BTCPayServer.Controllers { private readonly UserManager _userManager; private readonly SignInManager _signInManager; - private readonly EmailSenderFactory _EmailSenderFactory; - readonly StoreRepository storeRepository; readonly RoleManager _RoleManager; readonly SettingsRepository _SettingsRepository; readonly Configuration.BTCPayServerOptions _Options; @@ -41,19 +37,15 @@ namespace BTCPayServer.Controllers public AccountController( UserManager userManager, RoleManager roleManager, - StoreRepository storeRepository, SignInManager signInManager, - EmailSenderFactory emailSenderFactory, SettingsRepository settingsRepository, Configuration.BTCPayServerOptions options, BTCPayServerEnvironment btcPayServerEnvironment, U2FService u2FService, EventAggregator eventAggregator) { - this.storeRepository = storeRepository; _userManager = userManager; _signInManager = signInManager; - _EmailSenderFactory = emailSenderFactory; _RoleManager = roleManager; _SettingsRepository = settingsRepository; _Options = options; @@ -71,7 +63,7 @@ namespace BTCPayServer.Controllers [HttpGet] [AllowAnonymous] - public async Task Login(string returnUrl = null) + public async Task Login(string returnUrl = null, string email = null) { if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl)) @@ -85,7 +77,10 @@ namespace BTCPayServer.Controllers } 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}'."); } + 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] @@ -524,14 +541,10 @@ namespace BTCPayServer.Controllers // Don't reveal that the user does not exist or is not confirmed return RedirectToAction(nameof(ForgotPasswordConfirmation)); } - - // For more information on how to enable account confirmation and password reset please - // visit https://go.microsoft.com/fwlink/?LinkID=532713 - 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: link"); - + _eventAggregator.Publish(new UserPasswordResetRequestedEvent() + { + User = user, RequestUri = Request.GetAbsoluteRootUri() + }); return RedirectToAction(nameof(ForgotPasswordConfirmation)); } @@ -548,20 +561,27 @@ namespace BTCPayServer.Controllers [HttpGet] [AllowAnonymous] - public IActionResult ResetPassword(string code = null) + public async Task SetPassword(string code = null, string userId = null, string email = null) { if (code == null) { 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); } [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] - public async Task ResetPassword(ResetPasswordViewModel model) + public async Task SetPassword(SetPasswordViewModel model) { if (!ModelState.IsValid) { @@ -571,25 +591,23 @@ namespace BTCPayServer.Controllers if (user == null) { // 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); 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); return View(); } - [HttpGet] - [AllowAnonymous] - public IActionResult ResetPasswordConfirmation() - { - return View(); - } - - [HttpGet] public IActionResult AccessDenied() { diff --git a/BTCPayServer/Controllers/ServerController.Users.cs b/BTCPayServer/Controllers/ServerController.Users.cs new file mode 100644 index 000000000..bcb4b5928 --- /dev/null +++ b/BTCPayServer/Controllers/ServerController.Users.cs @@ -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 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 roles) + { + return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal); + } + + [Route("server/users/{userId}")] + [HttpPost] + public new async Task 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 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(); + + _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.
You may alternatively share this link with them: {callbackUrl}" + }); + }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.
You may alternatively share this link with them: {callbackUrl}" + }); + } + 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 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 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; } + } +} diff --git a/BTCPayServer/Controllers/ServerController.cs b/BTCPayServer/Controllers/ServerController.cs index 8bb990711..24497b815 100644 --- a/BTCPayServer/Controllers/ServerController.cs +++ b/BTCPayServer/Controllers/ServerController.cs @@ -26,6 +26,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using NBitcoin; @@ -47,6 +48,8 @@ namespace BTCPayServer.Controllers private readonly BTCPayServerOptions _Options; private readonly AppService _AppService; private readonly CheckConfigurationHostedService _sshState; + private readonly EventAggregator _eventAggregator; + private readonly CssThemeManager _cssThemeManager; private readonly StoredFileRepository _StoredFileRepository; private readonly FileService _FileService; private readonly IEnumerable _StorageProviderServices; @@ -63,7 +66,9 @@ namespace BTCPayServer.Controllers TorServices torServices, StoreRepository storeRepository, AppService appService, - CheckConfigurationHostedService sshState) + CheckConfigurationHostedService sshState, + EventAggregator eventAggregator, + CssThemeManager cssThemeManager) { _Options = options; _StoredFileRepository = storedFileRepository; @@ -78,37 +83,8 @@ namespace BTCPayServer.Controllers _torServices = torServices; _AppService = appService; _sshState = sshState; - } - - [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 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); + _eventAggregator = eventAggregator; + _cssThemeManager = cssThemeManager; } [Route("server/maintenance")] @@ -270,127 +246,7 @@ namespace BTCPayServer.Controllers sshClient.Dispose(); } } - - private static bool IsAdmin(IList roles) - { - return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal); - } - - [Route("server/users/{userId}")] - [HttpPost] - public new async Task 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 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 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 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; } [Route("server/policies")] diff --git a/BTCPayServer/Events/UserPasswordResetRequestedEvent.cs b/BTCPayServer/Events/UserPasswordResetRequestedEvent.cs new file mode 100644 index 000000000..9758315b1 --- /dev/null +++ b/BTCPayServer/Events/UserPasswordResetRequestedEvent.cs @@ -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 CallbackUrlGenerated; + } +} diff --git a/BTCPayServer/Events/UserRegisteredEvent.cs b/BTCPayServer/Events/UserRegisteredEvent.cs index d09477610..3b55906e3 100644 --- a/BTCPayServer/Events/UserRegisteredEvent.cs +++ b/BTCPayServer/Events/UserRegisteredEvent.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using BTCPayServer.Data; namespace BTCPayServer.Events @@ -8,5 +9,7 @@ namespace BTCPayServer.Events public ApplicationUser User { get; set; } public bool Admin { get; set; } public Uri RequestUri { get; set; } + + public TaskCompletionSource CallbackUrlGenerated; } } diff --git a/BTCPayServer/Extensions/EmailSenderExtensions.cs b/BTCPayServer/Extensions/EmailSenderExtensions.cs index 933e46fb9..f49197e0c 100644 --- a/BTCPayServer/Extensions/EmailSenderExtensions.cs +++ b/BTCPayServer/Extensions/EmailSenderExtensions.cs @@ -10,5 +10,12 @@ namespace BTCPayServer.Services emailSender.SendEmail(email, "Confirm your email", $"Please confirm your account by clicking this link: link"); } + + 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: link"); + } } } diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs index ea5cff3a4..f3b6d3795 100644 --- a/BTCPayServer/Extensions/UrlHelperExtensions.cs +++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs @@ -15,13 +15,16 @@ namespace Microsoft.AspNetCore.Mvc 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( - action: nameof(AccountController.ResetPassword), + return urlHelper.GetUriByAction( + action: nameof(AccountController.SetPassword), controller: "Account", 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) diff --git a/BTCPayServer/HostedServices/CssThemeManager.cs b/BTCPayServer/HostedServices/CssThemeManager.cs index fc9a0fbc1..8724e0733 100644 --- a/BTCPayServer/HostedServices/CssThemeManager.cs +++ b/BTCPayServer/HostedServices/CssThemeManager.cs @@ -54,34 +54,32 @@ namespace BTCPayServer.HostedServices } private string _creativeStartUri; + private PoliciesSettings _policies = new PoliciesSettings(); + + public PoliciesSettings Policies { get { return _policies; } } public string CreativeStartUri { get { return _creativeStartUri; } } - public bool ShowRegister { get; set; } - public bool DiscourageSearchEngines { get; set; } - - public AppType? RootAppType { get; set; } - public string RootAppId { get; set; } + public bool ShowRegister { get { return !_policies.LockSubscription; } } + public bool DiscourageSearchEngines { get { return _policies.DiscourageSearchEngines; } } + public AppType? RootAppType { get { return _policies.RootAppType; } } + public string RootAppId { get { return _policies.RootAppId; } } public bool FirstRun { get; set; } - public List DomainToAppMapping { get; set; } = new List(); + public List DomainToAppMapping { get { return _policies.DomainToAppMapping; } } internal void Update(PoliciesSettings data) { - ShowRegister = !data.LockSubscription; - DiscourageSearchEngines = data.DiscourageSearchEngines; - - RootAppType = data.RootAppType; - RootAppId = data.RootAppId; - DomainToAppMapping = data.DomainToAppMapping; - AllowLightningInternalNodeForAll = data.AllowLightningInternalNodeForAll; + _policies = data; + + } - public bool AllowLightningInternalNodeForAll { get; set; } + public bool AllowLightningInternalNodeForAll { get { return _policies.AllowLightningInternalNodeForAll; } } } public class ContentSecurityPolicyCssThemeManager : Attribute, IActionFilter, IOrderedFilter diff --git a/BTCPayServer/HostedServices/UserEventHostedService.cs b/BTCPayServer/HostedServices/UserEventHostedService.cs index c0584a02e..737d1a4b8 100644 --- a/BTCPayServer/HostedServices/UserEventHostedService.cs +++ b/BTCPayServer/HostedServices/UserEventHostedService.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Data; @@ -30,23 +31,60 @@ namespace BTCPayServer.HostedServices protected override void SubscribeToEvents() { Subscribe(); + Subscribe(); } protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) { + string code; + string callbackUrl; + UserPasswordResetRequestedEvent userPasswordResetRequestedEvent; switch (evt) { 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) { - - var code = await _userManager.GenerateEmailConfirmationTokenAsync(userRegisteredEvent.User); - var callbackUrl = _generator.EmailConfirmationLink(userRegisteredEvent.User.Id, code, userRegisteredEvent.RequestUri.Scheme, new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port), userRegisteredEvent.RequestUri.PathAndQuery); - + code = await _userManager.GenerateEmailConfirmationTokenAsync(userRegisteredEvent.User); + callbackUrl = _generator.EmailConfirmationLink(userRegisteredEvent.User.Id, code, + userRegisteredEvent.RequestUri.Scheme, + new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port), + userRegisteredEvent.RequestUri.PathAndQuery); + userRegisteredEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl)); _emailSenderFactory.GetEmailSender() .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; } } diff --git a/BTCPayServer/Models/AccountViewModels/ResetPasswordViewModel.cs b/BTCPayServer/Models/AccountViewModels/SetPasswordViewModel.cs similarity index 88% rename from BTCPayServer/Models/AccountViewModels/ResetPasswordViewModel.cs rename to BTCPayServer/Models/AccountViewModels/SetPasswordViewModel.cs index 1fd440157..5fe031551 100644 --- a/BTCPayServer/Models/AccountViewModels/ResetPasswordViewModel.cs +++ b/BTCPayServer/Models/AccountViewModels/SetPasswordViewModel.cs @@ -2,12 +2,11 @@ using System.ComponentModel.DataAnnotations; namespace BTCPayServer.Models.AccountViewModels { - public class ResetPasswordViewModel + public class SetPasswordViewModel { [Required] [EmailAddress] public string Email { get; set; } - [Required] [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] [DataType(DataType.Password)] @@ -19,5 +18,6 @@ namespace BTCPayServer.Models.AccountViewModels public string ConfirmPassword { get; set; } public string Code { get; set; } + public bool EmailSetInternally { get; set; } } } diff --git a/BTCPayServer/Views/Account/ConfirmEmail.cshtml b/BTCPayServer/Views/Account/ConfirmEmail.cshtml deleted file mode 100644 index 0f7158654..000000000 --- a/BTCPayServer/Views/Account/ConfirmEmail.cshtml +++ /dev/null @@ -1,14 +0,0 @@ -@{ - ViewData["Title"] = "Confirm email"; -} - -
-
- -
-
- -
-
-
-
diff --git a/BTCPayServer/Views/Account/ResetPassword.cshtml b/BTCPayServer/Views/Account/ResetPassword.cshtml deleted file mode 100644 index b4357e043..000000000 --- a/BTCPayServer/Views/Account/ResetPassword.cshtml +++ /dev/null @@ -1,45 +0,0 @@ -@model ResetPasswordViewModel -@{ - ViewData["Title"] = "Reset password"; -} - -
-
- @if (TempData.HasStatusMessage()) - { -
-
- -
-
- } -
-
-
-
- -
- - - -
-
- - - -
-
- - - -
- -
-
-
-
-
- -@section Scripts { - @await Html.PartialAsync("_ValidationScriptsPartial") -} diff --git a/BTCPayServer/Views/Account/ResetPasswordConfirmation.cshtml b/BTCPayServer/Views/Account/ResetPasswordConfirmation.cshtml deleted file mode 100644 index 37c35eb05..000000000 --- a/BTCPayServer/Views/Account/ResetPasswordConfirmation.cshtml +++ /dev/null @@ -1,14 +0,0 @@ -@{ - ViewData["Title"] = "Reset password confirmation"; -} - -

@ViewData["Title"]

-
-
-
-
- Your password has been reset. Please click here to log in. -
-
-
-
diff --git a/BTCPayServer/Views/Account/SetPassword.cshtml b/BTCPayServer/Views/Account/SetPassword.cshtml new file mode 100644 index 000000000..dd8f575c1 --- /dev/null +++ b/BTCPayServer/Views/Account/SetPassword.cshtml @@ -0,0 +1,76 @@ +@model BTCPayServer.Models.AccountViewModels.SetPasswordViewModel +@{ + ViewData["Title"] = "Reset password"; + + Layout = "_LayoutSimple"; +} + +
+
+ + BTCPay Server + + +

Welcome to your BTCPay Server

+ + @if (TempData.HasStatusMessage()) + { + + } +
+
+ +
+ +
+ +
+
+ +
+
diff --git a/BTCPayServer/Views/Manage/SetPassword.cshtml b/BTCPayServer/Views/Manage/SetPassword.cshtml index 940a0b1c4..f2991c2bf 100644 --- a/BTCPayServer/Views/Manage/SetPassword.cshtml +++ b/BTCPayServer/Views/Manage/SetPassword.cshtml @@ -1,4 +1,4 @@ -@model SetPasswordViewModel +@model BTCPayServer.Models.ManageViewModels.SetPasswordViewModel @{ ViewData.SetActivePageAndTitle(ManageNavPages.ChangePassword, "Set password"); } diff --git a/BTCPayServer/Views/Server/CreateUser.cshtml b/BTCPayServer/Views/Server/CreateUser.cshtml index 54781bc29..385a3f8bb 100644 --- a/BTCPayServer/Views/Server/CreateUser.cshtml +++ b/BTCPayServer/Views/Server/CreateUser.cshtml @@ -1,4 +1,4 @@ -@model RegisterViewModel +@model BTCPayServer.Controllers.RegisterFromAdminViewModel @{ ViewData.SetActivePageAndTitle(ServerNavPages.Users, $"Users - Create account"); } @@ -18,13 +18,13 @@
- +
- +
@@ -36,8 +36,18 @@ } + - + @if (ViewData["AllowRequestEmailConfirmation"] is true) + { +
+ + + +
+ } + + diff --git a/BTCPayServer/Views/Server/ListUsers.cshtml b/BTCPayServer/Views/Server/ListUsers.cshtml index 9b3c90148..77c5d71f1 100644 --- a/BTCPayServer/Views/Server/ListUsers.cshtml +++ b/BTCPayServer/Views/Server/ListUsers.cshtml @@ -9,7 +9,7 @@
Total Users: @Model.Total - + Add User diff --git a/BTCPayServer/Views/Server/_Nav.cshtml b/BTCPayServer/Views/Server/_Nav.cshtml index f148f3157..5553d6c86 100644 --- a/BTCPayServer/Views/Server/_Nav.cshtml +++ b/BTCPayServer/Views/Server/_Nav.cshtml @@ -1,10 +1,10 @@ -