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
This commit is contained in:
d11n 2024-02-28 12:43:18 +01:00 committed by GitHub
parent 8b446e2791
commit e43b4ed540
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 394 additions and 237 deletions

View file

@ -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();

View file

@ -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);
}

View file

@ -600,7 +600,6 @@ namespace BTCPayServer.Controllers
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? 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<IActionResult> 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<IActionResult> RedirectToConfirmEmail(ApplicationUser user)
{
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
return RedirectToAction(nameof(ConfirmEmail), new { userId = user.Id, code });
}
private async Task<IActionResult> 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)

View file

@ -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<IActionResult> 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<Uri>();
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<EmailSettings>() ?? new EmailSettings();
var info = settings.IsComplete()
? "An invitation email has been sent.<br/>You may alternatively"
: "An invitation email has not been sent, because the server does not have an email server configured.<br/> 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.<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>"
});
}
Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false,
Html = $"Account successfully created. {info} share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
});
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; }
}
}

View file

@ -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<IActionResult> Emails()
{
var email = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
@ -1191,8 +1187,7 @@ namespace BTCPayServer.Controllers
return View(vm);
}
[Route("server/emails")]
[HttpPost]
[HttpPost("server/emails")]
public async Task<IActionResult> Emails(ServerEmailsViewModel model, string command)
{
if (command == "Test")
@ -1246,7 +1241,7 @@ namespace BTCPayServer.Controllers
return View(model);
}
var oldSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
if (new EmailsViewModel(oldSettings).PasswordSet)
if (new ServerEmailsViewModel(oldSettings).PasswordSet)
{
model.Settings.Password = oldSettings.Password;
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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<Uri> 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<Uri> CallbackUrlGenerated;
}
public enum UserRegisteredEventKind
{
Registration,
Invite
}

View file

@ -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: <a href='{HtmlEncoder.Default.Encode(link)}'>link</a>");
emailSender.SendEmail(address, "BTCPay Server: Confirm your email",
$"Please confirm your account by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>.");
}
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 <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>");
emailSender.SendEmail(address, "BTCPay Server: Your account has been approved",
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>.");
}
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. <br/><br/> {CallToAction(subject, HtmlEncoder.Default.Encode(link))}";
emailSender.SendEmail(address, subject, $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>");
var body = $"A request has been made to reset your BTCPay Server password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", HtmlEncoder.Default.Encode(link))}";
emailSender.SendEmail(address, "BTCPay Server: Update Password", $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>");
}
public static void SendInvitation(this IEmailSender emailSender, MailboxAddress address, string link)
{
emailSender.SendEmail(address, "BTCPay Server: Invitation",
$"Please complete your account setup by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>.");
}
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: <a href='{HtmlEncoder.Default.Encode(link)}'>User details</a>");
}
}
}

View file

@ -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),

View file

@ -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<ApplicationUser> userManager,
EmailSenderFactory emailSenderFactory,
NotificationSender notificationSender,
LinkGenerator generator,
Logs logs)
: EventHostedServiceBase(eventAggregator, logs)
{
public class UserEventHostedService : EventHostedServiceBase
protected override void SubscribeToEvents()
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly EmailSenderFactory _emailSenderFactory;
private readonly NotificationSender _notificationSender;
private readonly LinkGenerator _generator;
Subscribe<UserRegisteredEvent>();
Subscribe<UserApprovedEvent>();
Subscribe<UserConfirmedEmailEvent>();
Subscribe<UserPasswordResetRequestedEvent>();
}
public UserEventHostedService(
EventAggregator eventAggregator,
UserManager<ApplicationUser> 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<UserRegisteredEvent>();
Subscribe<UserApprovedEvent>();
Subscribe<UserPasswordResetRequestedEvent>();
}
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);
}
}
}

View file

@ -88,7 +88,8 @@ namespace BTCPayServer.Hosting
.PersistKeysToFileSystem(new DirectoryInfo(new DataDirectories().Configure(Configuration).DataDir));
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
.AddDefaultTokenProviders()
.AddInvitationTokenProvider();
services.Configure<AuthenticationOptions>(opts =>
{
opts.DefaultAuthenticateScheme = null;

View file

@ -19,5 +19,6 @@ namespace BTCPayServer.Models.AccountViewModels
public string Code { get; set; }
public bool EmailSetInternally { get; set; }
public bool HasPassword { get; set; }
}
}

View file

@ -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);
}
}

View file

@ -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<TUser>(
IDataProtectionProvider dataProtectionProvider,
IOptions<InvitationTokenProviderOptions> options,
ILogger<DataProtectorTokenProvider<TUser>> logger)
: DataProtectorTokenProvider<TUser>(dataProtectionProvider, options, logger)
where TUser : class;

View file

@ -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<UserService> _logger;
@ -32,7 +29,6 @@ namespace BTCPayServer.Services
StoredFileRepository storedFileRepository,
FileService fileService,
EventAggregator eventAggregator,
StoreRepository storeRepository,
ApplicationDbContextFactory applicationDbContextFactory,
ILogger<UserService> 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);
}
}

View file

@ -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<TUser?> FindByIdOrEmail<TUser>(this UserManager<TUser> userManager, string? idOrEmail) where TUser : class
private const string InvitationPurpose = "invitation";
public static async Task<TUser?> FindByIdOrEmail<TUser>(this UserManager<TUser> 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<string> GenerateInvitationTokenAsync<TUser>(this UserManager<TUser> userManager, TUser user) where TUser : class
{
return await userManager.GenerateUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose);
}
public static async Task<TUser?> FindByInvitationTokenAsync<TUser>(this UserManager<TUser> 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;
}
}
}

View file

@ -12,6 +12,10 @@
@section PageHeadContent {
<style>
.alert {
max-width: 35em;
margin: var(--btcpay-space-l) auto;
}
.account-form {
max-width: 35em;
margin: 0 auto var(--btcpay-space-xl);

View file

@ -1,6 +1,6 @@
@model BTCPayServer.Models.AccountViewModels.SetPasswordViewModel
@{
ViewData["Title"] = "Reset password";
ViewData["Title"] = $"{(Model.HasPassword ? "Reset" : "Set")} your password";
Layout = "_LayoutSignedOut";
}

View file

@ -32,8 +32,6 @@
</form>
}
<partial name="_StatusMessage" />
<form method="post" asp-action="AuthorizeAPIKey">
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
<input type="hidden" asp-for="Permissions" value="@Model.Permissions"/>

View file

@ -12,8 +12,6 @@
</script>
}
<partial name="_StatusMessage" />
<form method="post" asp-controller="UIManage" asp-action="AuthorizeAPIKey">
<input type="hidden" asp-for="ApplicationName" value="@Model.ApplicationName"/>
<input type="hidden" asp-for="ApplicationIdentifier" value="@Model.ApplicationIdentifier"/>

View file

@ -44,14 +44,6 @@
<span asp-validation-for="EmailConfirmed" class="text-danger"></span>
</div>
}
@if (ViewData["AllowRequestApproval"] is true)
{
<div class="form-group form-check">
<input asp-for="Approved" type="checkbox" class="form-check-input"/>
<label asp-for="Approved" class="form-check-label"></label>
<span asp-validation-for="Approved" class="text-danger"></span>
</div>
}
<button id="Save" type="submit" class="btn btn-primary mt-2" name="command" value="Save">Create account</button>
</form>

View file

@ -83,11 +83,11 @@
@if (user is { EmailConfirmed: false, Disabled: false }) {
<a asp-action="SendVerificationEmail" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="Send verification email" data-description="This will send a verification email to <strong>@Html.Encode(user.Email)</strong>." data-confirm="Send">Resend email</a>
}
else if (user is { Approved: false, Disabled: false })
@if (user is { Approved: false, Disabled: false })
{
<a asp-action="ApproveUser" asp-route-userId="@user.Id" asp-route-approved="true" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="Approve user" data-description="This will approve the user <strong>@Html.Encode(user.Email)</strong>." data-confirm="Approve">Approve</a>
}
else
@if (status.Item2 != "warning")
{
<a asp-action="ToggleUser" asp-route-userId="@user.Id" asp-route-enable="@user.Disabled">@(user.Disabled ? "Enable" : "Disable")</a>
}

View file

@ -46,7 +46,6 @@
<p class="lead text-secondary">Create a store to begin accepting payments.</p>
<div class="text-start">
<partial name="_StatusMessage"/>
<partial name="_CreateStoreForm" model="Model" />
</div>
</div>