From e43b4ed54057b89fc74a4fb2607e3397723f06c2 Mon Sep 17 00:00:00 2001 From: d11n Date: Wed, 28 Feb 2024 12:43:18 +0100 Subject: [PATCH] Onboarding: Invite new users (#5714) * Server Users: More precise message when inviting users This lets the admin who invited a new user know whether or not an email has been sent. If the SMTP server hasn't been set up, they need to share the invite link with the user. * Onboarding: Invite new users - Separates the user self-registration and invite cases - Adds invitation email for users created by the admin - Adds invitation tokens to verify user was invited - Adds handler action for invite links - Refactors `UserEventHostedService` * Remove duplicate status message from views that use the wizard layout * Auto-approve users created by an admin * Notify admins via email if a new account requires approval * Update wording * Fix update user error * Fix redirect to email confirmation in invite action * Fix precondition checks after signup * Improve admin notification Send notification only if the user does not require email confirmation or when they confirmed their email address. Rationale: We want to inform admins only about qualified users and not annoy them with bot registrations. * Allow approval alongside resending confirm email * Use user email in log messages instead of ID * Prevent unnecessary notification after email confirmation * Use ApplicationUser type explicitly * Fix after rebase * Refactoring: Do not subclass UserRegisteredEvent --- BTCPayServer.Tests/SeleniumTests.cs | 17 +- .../GreenField/GreenfieldUsersController.cs | 21 +- .../Controllers/UIAccountController.cs | 109 +++++++-- .../Controllers/UIServerController.Users.cs | 61 ++--- .../Controllers/UIServerController.cs | 11 +- BTCPayServer/Events/UserApprovedEvent.cs | 12 +- .../Events/UserConfirmedEmailEvent.cs | 10 + BTCPayServer/Events/UserRegisteredEvent.cs | 24 +- .../Extensions/EmailSenderExtensions.cs | 27 ++- .../Extensions/UrlHelperExtensions.cs | 16 +- .../HostedServices/UserEventHostedService.cs | 213 ++++++++++-------- BTCPayServer/Hosting/Startup.cs | 3 +- .../AccountViewModels/SetPasswordViewModel.cs | 1 + .../Security/IdentityBuilderExtension.cs | 13 ++ .../Security/InvitationTokenProvider.cs | 26 +++ BTCPayServer/Services/UserService.cs | 23 +- BTCPayServer/UserManagerExtensions.cs | 21 +- .../Views/Shared/_LayoutSignedOut.cshtml | 4 + .../Views/UIAccount/SetPassword.cshtml | 2 +- .../Views/UIManage/AuthorizeAPIKey.cshtml | 2 - .../Views/UIManage/ConfirmAPIKey.cshtml | 2 - BTCPayServer/Views/UIServer/CreateUser.cshtml | 8 - BTCPayServer/Views/UIServer/ListUsers.cshtml | 4 +- .../Views/UIUserStores/CreateStore.cshtml | 1 - 24 files changed, 394 insertions(+), 237 deletions(-) create mode 100644 BTCPayServer/Events/UserConfirmedEmailEvent.cs create mode 100644 BTCPayServer/Security/IdentityBuilderExtension.cs create mode 100644 BTCPayServer/Security/InvitationTokenProvider.cs diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index f37eede0a..0140b1dbe 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -349,17 +349,18 @@ namespace BTCPayServer.Tests s.GoToHome(); //Change Password & Log Out + var newPassword = "abc???"; s.GoToProfile(ManageNavPages.ChangePassword); s.Driver.FindElement(By.Id("OldPassword")).SendKeys("123456"); - s.Driver.FindElement(By.Id("NewPassword")).SendKeys("abc???"); - s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("abc???"); + s.Driver.FindElement(By.Id("NewPassword")).SendKeys(newPassword); + s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys(newPassword); s.Driver.FindElement(By.Id("UpdatePassword")).Click(); s.Logout(); s.Driver.AssertNoError(); //Log In With New Password s.Driver.FindElement(By.Id("Email")).SendKeys(email); - s.Driver.FindElement(By.Id("Password")).SendKeys("abc???"); + s.Driver.FindElement(By.Id("Password")).SendKeys(newPassword); s.Driver.FindElement(By.Id("LoginButton")).Click(); s.GoToHome(); @@ -383,11 +384,14 @@ namespace BTCPayServer.Tests 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")); + Assert.Equal("Set your password", s.Driver.FindElement(By.CssSelector("h4")).Text); + Assert.Contains("Invitation accepted. Please set your password.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info).Text); s.Driver.FindElement(By.Id("Password")).SendKeys("123456"); s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456"); s.Driver.FindElement(By.Id("SetPassword")).Click(); - s.FindAlertMessage(); + Assert.Contains("Password successfully set.", s.FindAlertMessage().Text); + s.Driver.FindElement(By.Id("Email")).SendKeys(usr); s.Driver.FindElement(By.Id("Password")).SendKeys("123456"); s.Driver.FindElement(By.Id("LoginButton")).Click(); @@ -428,11 +432,6 @@ namespace BTCPayServer.Tests Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text); Assert.True(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected); - // Check user create view has approval checkbox - s.GoToServer(ServerNavPages.Users); - s.Driver.FindElement(By.Id("CreateUser")).Click(); - Assert.False(s.Driver.FindElement(By.Id("Approved")).Selected); - // Ensure there is no unread notification yet s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge")); s.Logout(); diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs index e829f4870..5b012f748 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs @@ -185,7 +185,7 @@ namespace BTCPayServer.Controllers.Greenfield RequiresEmailConfirmation = policies.RequiresConfirmedEmail, RequiresApproval = policies.RequiresUserApproval, Created = DateTimeOffset.UtcNow, - Approved = !anyAdmin && isAdmin // auto-approve first admin + Approved = isAdmin // auto-approve first admin and users created by an admin }; var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password); if (!passwordValidation.Succeeded) @@ -214,7 +214,8 @@ namespace BTCPayServer.Controllers.Greenfield return this.CreateValidationError(ModelState); } - if (request.IsAdministrator is true) + var isNewAdmin = request.IsAdministrator is true; + if (isNewAdmin) { if (!anyAdmin) { @@ -233,7 +234,21 @@ namespace BTCPayServer.Controllers.Greenfield await _settingsRepository.FirstAdminRegistered(policies, _options.UpdateUrl != null, _options.DisableRegistration, Logs); } } - _eventAggregator.Publish(new UserRegisteredEvent() { RequestUri = Request.GetAbsoluteRootUri(), User = user, Admin = request.IsAdministrator is true }); + + var currentUser = await _userManager.GetUserAsync(User); + var userEvent = new UserRegisteredEvent + { + RequestUri = Request.GetAbsoluteRootUri(), + Admin = isNewAdmin, + User = user + }; + if (currentUser is not null) + { + userEvent.Kind = UserRegisteredEventKind.Invite; + userEvent.InvitedByUser = currentUser; + }; + _eventAggregator.Publish(userEvent); + var model = await FromModel(user); return CreatedAtAction(string.Empty, model); } diff --git a/BTCPayServer/Controllers/UIAccountController.cs b/BTCPayServer/Controllers/UIAccountController.cs index e892defc8..74973817b 100644 --- a/BTCPayServer/Controllers/UIAccountController.cs +++ b/BTCPayServer/Controllers/UIAccountController.cs @@ -600,7 +600,6 @@ namespace BTCPayServer.Controllers var settings = await _SettingsRepository.GetSettingAsync() ?? new ThemeSettings(); settings.FirstRun = false; await _SettingsRepository.UpdateSetting(settings); - await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs); RegisteredAdmin = true; } @@ -614,15 +613,17 @@ namespace BTCPayServer.Controllers RegisteredUserId = user.Id; TempData[WellKnownTempData.SuccessMessage] = "Account created."; - if (policies.RequiresConfirmedEmail) + var requiresConfirmedEmail = policies.RequiresConfirmedEmail && !user.EmailConfirmed; + var requiresUserApproval = policies.RequiresUserApproval && !user.Approved; + if (requiresConfirmedEmail) { TempData[WellKnownTempData.SuccessMessage] += " Please confirm your email."; } - if (policies.RequiresUserApproval) + if (requiresUserApproval) { TempData[WellKnownTempData.SuccessMessage] += " The new account requires approval by an admin before you can log in."; } - if (policies.RequiresConfirmedEmail || policies.RequiresUserApproval) + if (requiresConfirmedEmail || requiresUserApproval) { return RedirectToAction(nameof(Login)); } @@ -670,25 +671,31 @@ namespace BTCPayServer.Controllers } var result = await _userManager.ConfirmEmailAsync(user, code); - if (!await _userManager.HasPasswordAsync(user)) + if (result.Succeeded) { + _eventAggregator.Publish(new UserConfirmedEmailEvent + { + User = user, + RequestUri = Request.GetAbsoluteRootUri() + }); + + var hasPassword = await _userManager.HasPasswordAsync(user); + if (hasPassword) + { + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = "Your email has been confirmed." + }); + return RedirectToAction(nameof(Login), new { email = user.Email }); + } TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Info, - Message = "Your email has been confirmed but you still need to set your password." + Message = "Your email has been confirmed. Please 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 await RedirectToSetPassword(user); } return View("Error"); @@ -743,14 +750,20 @@ namespace BTCPayServer.Controllers throw new ApplicationException("A code must be supplied for password reset."); } + var user = string.IsNullOrEmpty(userId) ? null : await _userManager.FindByIdAsync(userId); + var hasPassword = user != null && await _userManager.HasPasswordAsync(user); 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(new SetPasswordViewModel + { + Code = code, + Email = email, + EmailSetInternally = !string.IsNullOrEmpty(email), + HasPassword = hasPassword + }); } [HttpPost("/login/set-password")] @@ -762,6 +775,7 @@ namespace BTCPayServer.Controllers { return View(model); } + var user = await _userManager.FindByEmailAsync(model.Email); if (!UserService.TryCanLogin(user, out _)) { @@ -781,9 +795,64 @@ namespace BTCPayServer.Controllers } AddErrors(result); + model.HasPassword = await _userManager.HasPasswordAsync(user); return View(model); } + [AllowAnonymous] + [HttpGet("/invite/{userId}/{code}")] + public async Task AcceptInvite(string userId, string code) + { + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(code)) + { + return NotFound(); + } + + var user = await _userManager.FindByInvitationTokenAsync(userId, Uri.UnescapeDataString(code)); + if (user == null) + { + return NotFound(); + } + + var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed; + var requiresSetPassword = !await _userManager.HasPasswordAsync(user); + + if (requiresEmailConfirmation) + { + return await RedirectToConfirmEmail(user); + } + if (requiresSetPassword) + { + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Info, + Message = "Invitation accepted. Please set your password." + }); + return await RedirectToSetPassword(user); + } + + // Inform user that a password has been set on account creation + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Info, + Message = "Your password has been set by the user who invited you." + }); + + return RedirectToAction(nameof(Login), new { email = user.Email }); + } + + private async Task RedirectToConfirmEmail(ApplicationUser user) + { + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + return RedirectToAction(nameof(ConfirmEmail), new { userId = user.Id, code }); + } + + private async Task RedirectToSetPassword(ApplicationUser user) + { + var code = await _userManager.GeneratePasswordResetTokenAsync(user); + return RedirectToAction(nameof(SetPassword), new { userId = user.Id, email = user.Email, code }); + } + #region Helpers private void AddErrors(IdentityResult result) diff --git a/BTCPayServer/Controllers/UIServerController.Users.cs b/BTCPayServer/Controllers/UIServerController.Users.cs index 2869da0d9..28ecf8b9a 100644 --- a/BTCPayServer/Controllers/UIServerController.Users.cs +++ b/BTCPayServer/Controllers/UIServerController.Users.cs @@ -9,6 +9,7 @@ using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.Models.ServerViewModels; using BTCPayServer.Services; +using BTCPayServer.Services.Mails; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -102,7 +103,7 @@ namespace BTCPayServer.Controllers bool? adminStatusChanged = null; bool? approvalStatusChanged = null; - if (user.RequiresApproval && viewModel.Approved.HasValue) + if (user.RequiresApproval && viewModel.Approved.HasValue && user.Approved != viewModel.Approved.Value) { approvalStatusChanged = await _userService.SetUserApproval(user.Id, viewModel.Approved.Value, Request.GetAbsoluteRootUri()); } @@ -149,7 +150,6 @@ namespace BTCPayServer.Controllers [HttpGet("server/users/new")] public IActionResult CreateUser() { - ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval; ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail; return View(); } @@ -157,13 +157,11 @@ namespace BTCPayServer.Controllers [HttpPost("server/users/new")] public async Task CreateUser(RegisterFromAdminViewModel model) { - ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval; ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail; if (!_Options.CheatMode) model.IsAdmin = false; if (ModelState.IsValid) { - IdentityResult result; var user = new ApplicationUser { UserName = model.Email, @@ -171,18 +169,13 @@ namespace BTCPayServer.Controllers EmailConfirmed = model.EmailConfirmed, RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail, RequiresApproval = _policiesSettings.RequiresUserApproval, - Approved = model.Approved, + Approved = true, // auto-approve users created by an admin Created = DateTimeOffset.UtcNow }; - if (!string.IsNullOrEmpty(model.Password)) - { - result = await _UserManager.CreateAsync(user, model.Password); - } - else - { - result = await _UserManager.CreateAsync(user); - } + var result = string.IsNullOrEmpty(model.Password) + ? await _UserManager.CreateAsync(user) + : await _UserManager.CreateAsync(user, model.Password); if (result.Succeeded) { @@ -190,37 +183,30 @@ namespace BTCPayServer.Controllers model.IsAdmin = false; var tcs = new TaskCompletionSource(); + var currentUser = await _UserManager.GetUserAsync(HttpContext.User); - _eventAggregator.Publish(new UserRegisteredEvent() + _eventAggregator.Publish(new UserRegisteredEvent { + Kind = UserRegisteredEventKind.Invite, RequestUri = Request.GetAbsoluteRootUri(), User = user, - Admin = model.IsAdmin is true, + InvitedByUser = currentUser, + Admin = model.IsAdmin, CallbackUrlGenerated = tcs }); + var callbackUrl = await tcs.Task; - - if (user.RequiresEmailConfirmation && !user.EmailConfirmed) + var settings = await _SettingsRepository.GetSettingAsync() ?? new EmailSettings(); + var info = settings.IsComplete() + ? "An invitation email has been sent.
You may alternatively" + : "An invitation email has not been sent, because the server does not have an email server configured.
You need to"; + + TempData.SetStatusMessageModel(new StatusMessageModel { - - 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}" - }); - } + Severity = StatusMessageModel.StatusSeverity.Success, + AllowDismiss = false, + Html = $"Account successfully created. {info} share this link with them: {callbackUrl}" + }); return RedirectToAction(nameof(ListUsers)); } @@ -377,8 +363,5 @@ namespace BTCPayServer.Controllers [Display(Name = "Email confirmed?")] public bool EmailConfirmed { get; set; } - - [Display(Name = "User approved?")] - public bool Approved { get; set; } } } diff --git a/BTCPayServer/Controllers/UIServerController.cs b/BTCPayServer/Controllers/UIServerController.cs index 5d9bb6d61..8e553a7b4 100644 --- a/BTCPayServer/Controllers/UIServerController.cs +++ b/BTCPayServer/Controllers/UIServerController.cs @@ -16,9 +16,7 @@ using BTCPayServer.Abstractions.Models; using BTCPayServer.Configuration; using BTCPayServer.Data; using BTCPayServer.HostedServices; -using BTCPayServer.Hosting; using BTCPayServer.Logging; -using BTCPayServer.Models; using BTCPayServer.Models.ServerViewModels; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Payments; @@ -26,10 +24,8 @@ using BTCPayServer.Services; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Mails; using BTCPayServer.Services.Stores; -using BTCPayServer.Storage.Models; using BTCPayServer.Storage.Services; using BTCPayServer.Storage.Services.Providers; -using BTCPayServer.Validation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Identity; @@ -1180,7 +1176,7 @@ namespace BTCPayServer.Controllers return View(vm); } - [Route("server/emails")] + [HttpGet("server/emails")] public async Task Emails() { var email = await _SettingsRepository.GetSettingAsync() ?? new EmailSettings(); @@ -1191,8 +1187,7 @@ namespace BTCPayServer.Controllers return View(vm); } - [Route("server/emails")] - [HttpPost] + [HttpPost("server/emails")] public async Task Emails(ServerEmailsViewModel model, string command) { if (command == "Test") @@ -1246,7 +1241,7 @@ namespace BTCPayServer.Controllers return View(model); } var oldSettings = await _SettingsRepository.GetSettingAsync() ?? new EmailSettings(); - if (new EmailsViewModel(oldSettings).PasswordSet) + if (new ServerEmailsViewModel(oldSettings).PasswordSet) { model.Settings.Password = oldSettings.Password; } diff --git a/BTCPayServer/Events/UserApprovedEvent.cs b/BTCPayServer/Events/UserApprovedEvent.cs index 8b2989dcc..87264167b 100644 --- a/BTCPayServer/Events/UserApprovedEvent.cs +++ b/BTCPayServer/Events/UserApprovedEvent.cs @@ -1,12 +1,10 @@ using System; using BTCPayServer.Data; -namespace BTCPayServer.Events +namespace BTCPayServer.Events; + +public class UserApprovedEvent { - public class UserApprovedEvent - { - public ApplicationUser User { get; set; } - public bool Approved { get; set; } - public Uri RequestUri { get; set; } - } + public ApplicationUser User { get; set; } + public Uri RequestUri { get; set; } } diff --git a/BTCPayServer/Events/UserConfirmedEmailEvent.cs b/BTCPayServer/Events/UserConfirmedEmailEvent.cs new file mode 100644 index 000000000..dc9c69055 --- /dev/null +++ b/BTCPayServer/Events/UserConfirmedEmailEvent.cs @@ -0,0 +1,10 @@ +using System; +using BTCPayServer.Data; + +namespace BTCPayServer.Events; + +public class UserConfirmedEmailEvent +{ + public ApplicationUser User { get; set; } + public Uri RequestUri { get; set; } +} diff --git a/BTCPayServer/Events/UserRegisteredEvent.cs b/BTCPayServer/Events/UserRegisteredEvent.cs index 3b55906e3..fc8a0e36f 100644 --- a/BTCPayServer/Events/UserRegisteredEvent.cs +++ b/BTCPayServer/Events/UserRegisteredEvent.cs @@ -2,14 +2,20 @@ using System; using System.Threading.Tasks; using BTCPayServer.Data; -namespace BTCPayServer.Events -{ - public class UserRegisteredEvent - { - public ApplicationUser User { get; set; } - public bool Admin { get; set; } - public Uri RequestUri { get; set; } +namespace BTCPayServer.Events; - public TaskCompletionSource CallbackUrlGenerated; - } +public class UserRegisteredEvent +{ + public ApplicationUser User { get; set; } + public bool Admin { get; set; } + public UserRegisteredEventKind Kind { get; set; } = UserRegisteredEventKind.Registration; + public Uri RequestUri { get; set; } + public ApplicationUser InvitedByUser { get; set; } + public TaskCompletionSource CallbackUrlGenerated; +} + +public enum UserRegisteredEventKind +{ + Registration, + Invite } diff --git a/BTCPayServer/Extensions/EmailSenderExtensions.cs b/BTCPayServer/Extensions/EmailSenderExtensions.cs index dff303297..9fb81ad6f 100644 --- a/BTCPayServer/Extensions/EmailSenderExtensions.cs +++ b/BTCPayServer/Extensions/EmailSenderExtensions.cs @@ -19,21 +19,32 @@ namespace BTCPayServer.Services public static void SendEmailConfirmation(this IEmailSender emailSender, MailboxAddress address, string link) { - emailSender.SendEmail(address, "Confirm your email", - $"Please confirm your account by clicking this link: link"); + emailSender.SendEmail(address, "BTCPay Server: Confirm your email", + $"Please confirm your account by clicking this link."); } public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link) { - emailSender.SendEmail(address, "Your account has been approved", - $"Your account has been approved and you can now login here"); + emailSender.SendEmail(address, "BTCPay Server: Your account has been approved", + $"Your account has been approved and you can now login here."); } - public static void SendSetPasswordConfirmation(this IEmailSender emailSender, MailboxAddress address, string link, bool newPassword) + public static void SendResetPassword(this IEmailSender emailSender, MailboxAddress address, string link) { - var subject = $"{(newPassword ? "Set" : "Update")} Password"; - var body = $"A request has been made to {(newPassword ? "set" : "update")} your BTCPay Server password. Please confirm your password by clicking below.

{CallToAction(subject, HtmlEncoder.Default.Encode(link))}"; - emailSender.SendEmail(address, subject, $"{HEADER_HTML}{body}"); + var body = $"A request has been made to reset your BTCPay Server password. Please set your password by clicking below.

{CallToAction("Update Password", HtmlEncoder.Default.Encode(link))}"; + emailSender.SendEmail(address, "BTCPay Server: Update Password", $"{HEADER_HTML}{body}"); + } + + public static void SendInvitation(this IEmailSender emailSender, MailboxAddress address, string link) + { + emailSender.SendEmail(address, "BTCPay Server: Invitation", + $"Please complete your account setup by clicking this link."); + } + + public static void SendNewUserInfo(this IEmailSender emailSender, MailboxAddress address, string newUserInfo, string link) + { + emailSender.SendEmail(address, $"BTCPay Server: {newUserInfo}", + $"{newUserInfo}. You can verify and approve the account here: User details"); } } } diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs index 318ac52f2..1fb91278e 100644 --- a/BTCPayServer/Extensions/UrlHelperExtensions.cs +++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs @@ -23,6 +23,19 @@ namespace Microsoft.AspNetCore.Mvc return null; } #nullable restore + + public static string UserDetailsLink(this LinkGenerator urlHelper, string userId, string scheme, HostString host, string pathbase) + { + return urlHelper.GetUriByAction(nameof(UIServerController.User), "UIServer", + new { userId }, scheme, host, pathbase); + } + + public static string InvitationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase) + { + return urlHelper.GetUriByAction(nameof(UIAccountController.AcceptInvite), "UIAccount", + new { userId, code }, scheme, host, pathbase); + } + public static string EmailConfirmationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase) { return urlHelper.GetUriByAction(nameof(UIAccountController.ConfirmEmail), "UIAccount", @@ -33,8 +46,7 @@ namespace Microsoft.AspNetCore.Mvc { return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", null , scheme, host, pathbase); } - - public static string ResetPasswordCallbackLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase) + public static string ResetPasswordLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase) { return urlHelper.GetUriByAction( action: nameof(UIAccountController.SetPassword), diff --git a/BTCPayServer/HostedServices/UserEventHostedService.cs b/BTCPayServer/HostedServices/UserEventHostedService.cs index 278f9c69b..2cfce8825 100644 --- a/BTCPayServer/HostedServices/UserEventHostedService.cs +++ b/BTCPayServer/HostedServices/UserEventHostedService.cs @@ -13,112 +13,129 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; -using MimeKit; -namespace BTCPayServer.HostedServices +namespace BTCPayServer.HostedServices; + +public class UserEventHostedService( + EventAggregator eventAggregator, + UserManager userManager, + EmailSenderFactory emailSenderFactory, + NotificationSender notificationSender, + LinkGenerator generator, + Logs logs) + : EventHostedServiceBase(eventAggregator, logs) { - public class UserEventHostedService : EventHostedServiceBase + protected override void SubscribeToEvents() { - private readonly UserManager _userManager; - private readonly EmailSenderFactory _emailSenderFactory; - private readonly NotificationSender _notificationSender; - private readonly LinkGenerator _generator; + Subscribe(); + Subscribe(); + Subscribe(); + Subscribe(); + } - public UserEventHostedService( - EventAggregator eventAggregator, - UserManager userManager, - EmailSenderFactory emailSenderFactory, - NotificationSender notificationSender, - LinkGenerator generator, - Logs logs) : base(eventAggregator, logs) + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + string code; + string callbackUrl; + Uri uri; + HostString host; + ApplicationUser user; + IEmailSender emailSender; + switch (evt) { - _userManager = userManager; - _emailSenderFactory = emailSenderFactory; - _notificationSender = notificationSender; - _generator = generator; + case UserRegisteredEvent ev: + user = ev.User; + uri = ev.RequestUri; + host = new HostString(uri.Host, uri.Port); + + // can be either a self-registration or by invite from another user + var isInvite = ev.Kind == UserRegisteredEventKind.Invite; + var type = ev.Admin ? "admin" : "user"; + var info = isInvite ? ev.InvitedByUser != null ? $"invited by {ev.InvitedByUser.Email}" : "invited" : "registered"; + var requiresApproval = user.RequiresApproval && !user.Approved; + var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed; + + // log registration info + var newUserInfo = $"New {type} {user.Email} {info}"; + Logs.PayServer.LogInformation(newUserInfo); + + // send notification if the user does not require email confirmation. + // inform admins only about qualified users and not annoy them with bot registrations. + if (requiresApproval && !requiresEmailConfirmation) + { + await NotifyAdminsAboutUserRequiringApproval(user, uri, newUserInfo); + } + + // set callback result and send email to user + emailSender = await emailSenderFactory.GetEmailSender(); + if (isInvite) + { + code = await userManager.GenerateInvitationTokenAsync(user); + callbackUrl = generator.InvitationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery); + ev.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl)); + + emailSender.SendInvitation(user.GetMailboxAddress(), callbackUrl); + } + else if (requiresEmailConfirmation) + { + code = await userManager.GenerateEmailConfirmationTokenAsync(user); + callbackUrl = generator.EmailConfirmationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery); + ev.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl)); + + emailSender.SendEmailConfirmation(user.GetMailboxAddress(), callbackUrl); + } + else + { + ev.CallbackUrlGenerated?.SetResult(null); + } + break; + + case UserPasswordResetRequestedEvent pwResetEvent: + user = pwResetEvent.User; + uri = pwResetEvent.RequestUri; + host = new HostString(uri.Host, uri.Port); + code = await userManager.GeneratePasswordResetTokenAsync(user); + callbackUrl = generator.ResetPasswordLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery); + pwResetEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl)); + Logs.PayServer.LogInformation("User {Email} requested a password reset", user.Email); + emailSender = await emailSenderFactory.GetEmailSender(); + emailSender.SendResetPassword(user.GetMailboxAddress(), callbackUrl); + break; + + case UserApprovedEvent approvedEvent: + user = approvedEvent.User; + if (!user.Approved) break; + uri = approvedEvent.RequestUri; + host = new HostString(uri.Host, uri.Port); + callbackUrl = generator.LoginLink(uri.Scheme, host, uri.PathAndQuery); + emailSender = await emailSenderFactory.GetEmailSender(); + emailSender.SendApprovalConfirmation(user.GetMailboxAddress(), callbackUrl); + break; + + case UserConfirmedEmailEvent confirmedEvent: + user = confirmedEvent.User; + if (!user.EmailConfirmed) break; + uri = confirmedEvent.RequestUri; + var confirmedUserInfo = $"User {user.Email} confirmed their email address"; + Logs.PayServer.LogInformation(confirmedUserInfo); + if (!user.RequiresApproval || user.Approved) return; + await NotifyAdminsAboutUserRequiringApproval(user, uri, confirmedUserInfo); + break; } + } - protected override void SubscribeToEvents() + private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, Uri uri, string newUserInfo) + { + if (!user.RequiresApproval || user.Approved) return; + await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user)); + + var admins = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin); + var host = new HostString(uri.Host, uri.Port); + var approvalLink = generator.UserDetailsLink(user.Id, uri.Scheme, host, uri.PathAndQuery); + var emailSender = await emailSenderFactory.GetEmailSender(); + foreach (var admin in admins) { - Subscribe(); - Subscribe(); - Subscribe(); - } - - protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) - { - string code; - string callbackUrl; - Uri uri; - HostString host; - ApplicationUser user; - MailboxAddress address; - IEmailSender emailSender; - UserPasswordResetRequestedEvent userPasswordResetRequestedEvent; - switch (evt) - { - case UserRegisteredEvent userRegisteredEvent: - user = userRegisteredEvent.User; - Logs.PayServer.LogInformation( - $"A new user just registered {user.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}"); - if (user.RequiresApproval && !user.Approved) - { - await _notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user)); - } - if (!user.EmailConfirmed && user.RequiresEmailConfirmation) - { - uri = userRegisteredEvent.RequestUri; - host = new HostString(uri.Host, uri.Port); - code = await _userManager.GenerateEmailConfirmationTokenAsync(user); - callbackUrl = _generator.EmailConfirmationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery); - userRegisteredEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl)); - address = user.GetMailboxAddress(); - emailSender = await _emailSenderFactory.GetEmailSender(); - emailSender.SendEmailConfirmation(address, callbackUrl); - } - else if (!await _userManager.HasPasswordAsync(userRegisteredEvent.User)) - { - userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent - { - CallbackUrlGenerated = userRegisteredEvent.CallbackUrlGenerated, - User = user, - RequestUri = userRegisteredEvent.RequestUri - }; - goto passwordSetter; - } - else - { - userRegisteredEvent.CallbackUrlGenerated?.SetResult(null); - } - break; - - case UserApprovedEvent userApprovedEvent: - if (userApprovedEvent.Approved) - { - uri = userApprovedEvent.RequestUri; - host = new HostString(uri.Host, uri.Port); - address = userApprovedEvent.User.GetMailboxAddress(); - callbackUrl = _generator.LoginLink(uri.Scheme, host, uri.PathAndQuery); - emailSender = await _emailSenderFactory.GetEmailSender(); - emailSender.SendApprovalConfirmation(address, callbackUrl); - } - break; - - case UserPasswordResetRequestedEvent userPasswordResetRequestedEvent2: - userPasswordResetRequestedEvent = userPasswordResetRequestedEvent2; -passwordSetter: - uri = userPasswordResetRequestedEvent.RequestUri; - host = new HostString(uri.Host, uri.Port); - user = userPasswordResetRequestedEvent.User; - code = await _userManager.GeneratePasswordResetTokenAsync(user); - var newPassword = await _userManager.HasPasswordAsync(user); - callbackUrl = _generator.ResetPasswordCallbackLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery); - userPasswordResetRequestedEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl)); - address = user.GetMailboxAddress(); - emailSender = await _emailSenderFactory.GetEmailSender(); - emailSender.SendSetPasswordConfirmation(address, callbackUrl, newPassword); - break; - } + emailSender.SendNewUserInfo(admin.GetMailboxAddress(), newUserInfo, approvalLink); } } } diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs index 7cb8cff10..e4303b3bc 100644 --- a/BTCPayServer/Hosting/Startup.cs +++ b/BTCPayServer/Hosting/Startup.cs @@ -88,7 +88,8 @@ namespace BTCPayServer.Hosting .PersistKeysToFileSystem(new DirectoryInfo(new DataDirectories().Configure(Configuration).DataDir)); services.AddIdentity() .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); + .AddDefaultTokenProviders() + .AddInvitationTokenProvider(); services.Configure(opts => { opts.DefaultAuthenticateScheme = null; diff --git a/BTCPayServer/Models/AccountViewModels/SetPasswordViewModel.cs b/BTCPayServer/Models/AccountViewModels/SetPasswordViewModel.cs index 5fe031551..110def55f 100644 --- a/BTCPayServer/Models/AccountViewModels/SetPasswordViewModel.cs +++ b/BTCPayServer/Models/AccountViewModels/SetPasswordViewModel.cs @@ -19,5 +19,6 @@ namespace BTCPayServer.Models.AccountViewModels public string Code { get; set; } public bool EmailSetInternally { get; set; } + public bool HasPassword { get; set; } } } diff --git a/BTCPayServer/Security/IdentityBuilderExtension.cs b/BTCPayServer/Security/IdentityBuilderExtension.cs new file mode 100644 index 000000000..33d7f1a90 --- /dev/null +++ b/BTCPayServer/Security/IdentityBuilderExtension.cs @@ -0,0 +1,13 @@ +using BTCPayServer.Data; +using Microsoft.AspNetCore.Identity; + +namespace BTCPayServer.Security; + +public static class IdentityBuilderExtension +{ + public static IdentityBuilder AddInvitationTokenProvider(this IdentityBuilder builder) + { + var provider = typeof(InvitationTokenProvider<>).MakeGenericType(typeof(ApplicationUser)); + return builder.AddTokenProvider(InvitationTokenProviderOptions.ProviderName, provider); + } +} diff --git a/BTCPayServer/Security/InvitationTokenProvider.cs b/BTCPayServer/Security/InvitationTokenProvider.cs new file mode 100644 index 000000000..f4f95df8e --- /dev/null +++ b/BTCPayServer/Security/InvitationTokenProvider.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace BTCPayServer.Security; + +// https://andrewlock.net/implementing-custom-token-providers-for-passwordless-authentication-in-asp-net-core-identity/ +public class InvitationTokenProviderOptions : DataProtectionTokenProviderOptions +{ + public const string ProviderName = "InvitationTokenProvider"; + + public InvitationTokenProviderOptions() + { + Name = ProviderName; + TokenLifespan = TimeSpan.FromDays(7); + } +} + +public class InvitationTokenProvider( + IDataProtectionProvider dataProtectionProvider, + IOptions options, + ILogger> logger) + : DataProtectorTokenProvider(dataProtectionProvider, options, logger) + where TUser : class; diff --git a/BTCPayServer/Services/UserService.cs b/BTCPayServer/Services/UserService.cs index c19a58aa8..868f8a521 100644 --- a/BTCPayServer/Services/UserService.cs +++ b/BTCPayServer/Services/UserService.cs @@ -4,11 +4,9 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; -using BTCPayServer; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Events; -using BTCPayServer.Services.Stores; using BTCPayServer.Storage.Services; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -22,7 +20,6 @@ namespace BTCPayServer.Services private readonly IServiceProvider _serviceProvider; private readonly StoredFileRepository _storedFileRepository; private readonly FileService _fileService; - private readonly StoreRepository _storeRepository; private readonly EventAggregator _eventAggregator; private readonly ApplicationDbContextFactory _applicationDbContextFactory; private readonly ILogger _logger; @@ -32,7 +29,6 @@ namespace BTCPayServer.Services StoredFileRepository storedFileRepository, FileService fileService, EventAggregator eventAggregator, - StoreRepository storeRepository, ApplicationDbContextFactory applicationDbContextFactory, ILogger logger) { @@ -40,7 +36,6 @@ namespace BTCPayServer.Services _storedFileRepository = storedFileRepository; _fileService = fileService; _eventAggregator = eventAggregator; - _storeRepository = storeRepository; _applicationDbContextFactory = applicationDbContextFactory; _logger = logger; } @@ -124,12 +119,12 @@ namespace BTCPayServer.Services var succeeded = await userManager.UpdateAsync(user) is { Succeeded: true }; if (succeeded) { - _logger.LogInformation("User {UserId} is now {Status}", user.Id, approved ? "approved" : "unapproved"); - _eventAggregator.Publish(new UserApprovedEvent { User = user, Approved = approved, RequestUri = requestUri }); + _logger.LogInformation("User {Email} is now {Status}", user.Email, approved ? "approved" : "unapproved"); + _eventAggregator.Publish(new UserApprovedEvent { User = user, RequestUri = requestUri }); } else { - _logger.LogError("Failed to {Action} user {UserId}", approved ? "approve" : "unapprove", user.Id); + _logger.LogError("Failed to {Action} user {Email}", approved ? "approve" : "unapprove", user.Email); } return succeeded; @@ -152,11 +147,11 @@ namespace BTCPayServer.Services var res = await userManager.SetLockoutEndDateAsync(user, lockedOutDeadline); if (res.Succeeded) { - _logger.LogInformation($"User {user.Id} is now {(lockedOutDeadline is null ? "unlocked" : "locked")}"); + _logger.LogInformation("User {Email} is now {Status}", user.Email, (lockedOutDeadline is null ? "unlocked" : "locked")); } else { - _logger.LogError($"Failed to set lockout for user {user.Id}"); + _logger.LogError("Failed to set lockout for user {Email}", user.Email); } return res.Succeeded; @@ -195,11 +190,11 @@ namespace BTCPayServer.Services if (res.Succeeded) { - _logger.LogInformation($"Successfully set admin status for user {user.Id}"); + _logger.LogInformation("Successfully set admin status for user {Email}", user.Email); } else { - _logger.LogError($"Error setting admin status for user {user.Id}"); + _logger.LogError("Error setting admin status for user {Email}", user.Email); } return res.Succeeded; @@ -224,11 +219,11 @@ namespace BTCPayServer.Services var res = await userManager.DeleteAsync(user); if (res.Succeeded) { - _logger.LogInformation($"User {user.Id} was successfully deleted"); + _logger.LogInformation("User {Email} was successfully deleted", user.Email); } else { - _logger.LogError($"Failed to delete user {user.Id}"); + _logger.LogError("Failed to delete user {Email}", user.Email); } } diff --git a/BTCPayServer/UserManagerExtensions.cs b/BTCPayServer/UserManagerExtensions.cs index de6750d8a..2f5acd387 100644 --- a/BTCPayServer/UserManagerExtensions.cs +++ b/BTCPayServer/UserManagerExtensions.cs @@ -1,19 +1,34 @@ #nullable enable using System.Threading.Tasks; +using BTCPayServer.Security; using Microsoft.AspNetCore.Identity; namespace BTCPayServer { public static class UserManagerExtensions { - public async static Task FindByIdOrEmail(this UserManager userManager, string? idOrEmail) where TUser : class + private const string InvitationPurpose = "invitation"; + + public static async Task FindByIdOrEmail(this UserManager userManager, string? idOrEmail) where TUser : class { if (string.IsNullOrEmpty(idOrEmail)) return null; if (idOrEmail.Contains('@')) return await userManager.FindByEmailAsync(idOrEmail); - else - return await userManager.FindByIdAsync(idOrEmail); + + return await userManager.FindByIdAsync(idOrEmail); + } + + public static async Task GenerateInvitationTokenAsync(this UserManager userManager, TUser user) where TUser : class + { + return await userManager.GenerateUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose); + } + + public static async Task FindByInvitationTokenAsync(this UserManager userManager, string userId, string token) where TUser : class + { + var user = await userManager.FindByIdAsync(userId); + var isValid = user is not null && await userManager.VerifyUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose, token); + return isValid ? user : null; } } } diff --git a/BTCPayServer/Views/Shared/_LayoutSignedOut.cshtml b/BTCPayServer/Views/Shared/_LayoutSignedOut.cshtml index a1b84516e..151466adf 100644 --- a/BTCPayServer/Views/Shared/_LayoutSignedOut.cshtml +++ b/BTCPayServer/Views/Shared/_LayoutSignedOut.cshtml @@ -12,6 +12,10 @@ @section PageHeadContent {