Invitation process improvements (#6188)

* Server: Make sending email optional when adding user

Closes #6158.

* Generate custom invite token and store it in user blob

Closes btcpayserver/app/#46.

* QR code for user invite

Closes #6157.

* Text fix
This commit is contained in:
d11n
2024-09-12 05:31:57 +02:00
committed by GitHub
parent 3342122be2
commit f3d485da53
11 changed files with 134 additions and 34 deletions

View File

@@ -46,5 +46,6 @@ namespace BTCPayServer.Data
public bool ShowInvoiceStatusChangeHint { get; set; } public bool ShowInvoiceStatusChangeHint { get; set; }
public string ImageUrl { get; set; } public string ImageUrl { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string InvitationToken { get; set; }
} }
} }

View File

@@ -371,7 +371,7 @@ namespace BTCPayServer.Tests
var usr = RandomUtils.GetUInt256().ToString().Substring(64 - 20) + "@a.com"; var usr = RandomUtils.GetUInt256().ToString().Substring(64 - 20) + "@a.com";
s.Driver.FindElement(By.Id("Email")).SendKeys(usr); s.Driver.FindElement(By.Id("Email")).SendKeys(usr);
s.ClickPagePrimary(); s.ClickPagePrimary();
var url = s.FindAlertMessage().FindElement(By.TagName("a")).Text; var url = s.Driver.FindElement(By.Id("InvitationUrl")).GetAttribute("data-text");
s.Logout(); s.Logout();
s.Driver.Navigate().GoToUrl(url); s.Driver.Navigate().GoToUrl(url);

View File

@@ -812,7 +812,7 @@ namespace BTCPayServer.Controllers
return NotFound(); return NotFound();
} }
var user = await _userManager.FindByInvitationTokenAsync(userId, Uri.UnescapeDataString(code)); var user = await _userManager.FindByInvitationTokenAsync<ApplicationUser>(userId, Uri.UnescapeDataString(code));
if (user == null) if (user == null)
{ {
return NotFound(); return NotFound();
@@ -827,6 +827,9 @@ namespace BTCPayServer.Controllers
RequestUri = Request.GetAbsoluteRootUri() RequestUri = Request.GetAbsoluteRootUri()
}); });
// unset used token
await _userManager.UnsetInvitationTokenAsync<ApplicationUser>(user.Id);
if (requiresEmailConfirmation) if (requiresEmailConfirmation)
{ {
return await RedirectToConfirmEmail(user); return await RedirectToConfirmEmail(user);

View File

@@ -58,18 +58,28 @@ namespace BTCPayServer.Controllers
.Skip(model.Skip) .Skip(model.Skip)
.Take(model.Count) .Take(model.Count)
.ToListAsync()) .ToListAsync())
.Select(u => new UsersViewModel.UserViewModel .Select(u =>
{ {
Name = u.GetBlob()?.Name, var blob = u.GetBlob();
ImageUrl = u.GetBlob()?.ImageUrl, return new UsersViewModel.UserViewModel
Email = u.Email, {
Id = u.Id, Name = blob?.Name,
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null, ImageUrl = blob?.ImageUrl,
Approved = u.RequiresApproval ? u.Approved : null, Email = u.Email,
Created = u.Created, Id = u.Id,
Roles = u.UserRoles.Select(role => role.RoleId), InvitationUrl =
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime, string.IsNullOrEmpty(blob?.InvitationToken)
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList() ? null
: _linkGenerator.InvitationLink(u.Id, blob.InvitationToken, Request.Scheme,
Request.Host, Request.PathBase),
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
Approved = u.RequiresApproval ? u.Approved : null,
Created = u.Created,
Roles = u.UserRoles.Select(role => role.RoleId),
Disabled = u.LockoutEnabled && u.LockoutEnd != null &&
DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime,
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList()
};
}) })
.ToList(); .ToList();
return View(model); return View(model);
@@ -88,6 +98,7 @@ namespace BTCPayServer.Controllers
Id = user.Id, Id = user.Id,
Email = user.Email, Email = user.Email,
Name = blob?.Name, Name = blob?.Name,
InvitationUrl = string.IsNullOrEmpty(blob?.InvitationToken) ? null : _linkGenerator.InvitationLink(user.Id, blob.InvitationToken, Request.Scheme, Request.Host, Request.PathBase),
ImageUrl = string.IsNullOrEmpty(blob?.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)), ImageUrl = string.IsNullOrEmpty(blob?.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null, EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
Approved = user.RequiresApproval ? user.Approved : null, Approved = user.RequiresApproval ? user.Approved : null,
@@ -200,16 +211,20 @@ namespace BTCPayServer.Controllers
} }
[HttpGet("server/users/new")] [HttpGet("server/users/new")]
public IActionResult CreateUser() public async Task<IActionResult> CreateUser()
{ {
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail; await PrepareCreateUserViewData();
return View(); var vm = new RegisterFromAdminViewModel
{
SendInvitationEmail = ViewData["CanSendEmail"] is true
};
return View(vm);
} }
[HttpPost("server/users/new")] [HttpPost("server/users/new")]
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model) public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
{ {
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail; await PrepareCreateUserViewData();
if (!_Options.CheatMode) if (!_Options.CheatMode)
model.IsAdmin = false; model.IsAdmin = false;
if (ModelState.IsValid) if (ModelState.IsValid)
@@ -236,6 +251,7 @@ namespace BTCPayServer.Controllers
var tcs = new TaskCompletionSource<Uri>(); var tcs = new TaskCompletionSource<Uri>();
var currentUser = await _UserManager.GetUserAsync(HttpContext.User); var currentUser = await _UserManager.GetUserAsync(HttpContext.User);
var sendEmail = model.SendInvitationEmail && ViewData["CanSendEmail"] is true;
_eventAggregator.Publish(new UserRegisteredEvent _eventAggregator.Publish(new UserRegisteredEvent
{ {
@@ -243,23 +259,23 @@ namespace BTCPayServer.Controllers
Kind = UserRegisteredEventKind.Invite, Kind = UserRegisteredEventKind.Invite,
User = user, User = user,
InvitedByUser = currentUser, InvitedByUser = currentUser,
SendInvitationEmail = sendEmail,
Admin = model.IsAdmin, Admin = model.IsAdmin,
CallbackUrlGenerated = tcs CallbackUrlGenerated = tcs
}); });
var callbackUrl = await tcs.Task; var callbackUrl = await tcs.Task;
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings(); var info = sendEmail
var info = settings.IsComplete() ? "An invitation email has been sent. You may alternatively"
? "An invitation email has been sent.<br/>You may alternatively" : "An invitation email has not been sent. You need to";
: "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, Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false, AllowDismiss = false,
Html = $"Account successfully created. {info} share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>" Html = $"Account successfully created. {info} share this link with them:<br/>{callbackUrl}"
}); });
return RedirectToAction(nameof(ListUsers)); return RedirectToAction(nameof(User), new { userId = user.Id });
} }
foreach (var error in result.Errors) foreach (var error in result.Errors)
@@ -391,6 +407,13 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.SuccessMessage] = "Verification email sent"; TempData[WellKnownTempData.SuccessMessage] = "Verification email sent";
return RedirectToAction(nameof(ListUsers)); return RedirectToAction(nameof(ListUsers));
} }
private async Task PrepareCreateUserViewData()
{
var emailSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
ViewData["CanSendEmail"] = emailSettings.IsComplete();
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
}
} }
public class RegisterFromAdminViewModel public class RegisterFromAdminViewModel
@@ -415,5 +438,8 @@ namespace BTCPayServer.Controllers
[Display(Name = "Email confirmed?")] [Display(Name = "Email confirmed?")]
public bool EmailConfirmed { get; set; } public bool EmailConfirmed { get; set; }
[Display(Name = "Send invitation email")]
public bool SendInvitationEmail { get; set; } = true;
} }
} }

View File

@@ -11,6 +11,7 @@ public class UserRegisteredEvent
public UserRegisteredEventKind Kind { get; set; } = UserRegisteredEventKind.Registration; public UserRegisteredEventKind Kind { get; set; } = UserRegisteredEventKind.Registration;
public Uri RequestUri { get; set; } public Uri RequestUri { get; set; }
public ApplicationUser InvitedByUser { get; set; } public ApplicationUser InvitedByUser { get; set; }
public bool SendInvitationEmail { get; set; }
public TaskCompletionSource<Uri> CallbackUrlGenerated; public TaskCompletionSource<Uri> CallbackUrlGenerated;
} }

View File

@@ -73,11 +73,12 @@ public class UserEventHostedService(
emailSender = await emailSenderFactory.GetEmailSender(); emailSender = await emailSenderFactory.GetEmailSender();
if (isInvite) if (isInvite)
{ {
code = await userManager.GenerateInvitationTokenAsync(user); code = await userManager.GenerateInvitationTokenAsync<ApplicationUser>(user.Id);
callbackUrl = generator.InvitationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery); callbackUrl = generator.InvitationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
ev.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl)); ev.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
emailSender.SendInvitation(user.GetMailboxAddress(), callbackUrl); if (ev.SendInvitationEmail)
emailSender.SendInvitation(user.GetMailboxAddress(), callbackUrl);
} }
else if (requiresEmailConfirmation) else if (requiresEmailConfirmation)
{ {

View File

@@ -14,7 +14,8 @@ namespace BTCPayServer.Models.ServerViewModels
public string Id { get; set; } public string Id { get; set; }
public string Email { get; set; } public string Email { get; set; }
public string Name { get; set; } public string Name { get; set; }
[Display(Name = "Invitation URL")]
public string InvitationUrl { get; set; }
[Display(Name = "Image")] [Display(Name = "Image")]
public IFormFile ImageFile { get; set; } public IFormFile ImageFile { get; set; }
public string ImageUrl { get; set; } public string ImageUrl { get; set; }

View File

@@ -1,5 +1,7 @@
#nullable enable #nullable enable
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Security; using BTCPayServer.Security;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -19,15 +21,35 @@ namespace BTCPayServer
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 public static async Task<string?> GenerateInvitationTokenAsync<TUser>(this UserManager<ApplicationUser> userManager, string userId) where TUser : class
{ {
return await userManager.GenerateUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose); var token = Guid.NewGuid().ToString("n")[..12];
return await userManager.SetInvitationTokenAsync<TUser>(userId, token) ? token : null;
} }
public static async Task<TUser?> FindByInvitationTokenAsync<TUser>(this UserManager<TUser> userManager, string userId, string token) where TUser : class public static async Task<bool> UnsetInvitationTokenAsync<TUser>(this UserManager<ApplicationUser> userManager, string userId) where TUser : class
{
return await userManager.SetInvitationTokenAsync<TUser>(userId, null);
}
private static async Task<bool> SetInvitationTokenAsync<TUser>(this UserManager<ApplicationUser> userManager, string userId, string? token) where TUser : class
{ {
var user = await userManager.FindByIdAsync(userId); var user = await userManager.FindByIdAsync(userId);
var isValid = user is not null && await userManager.VerifyUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose, token); if (user == null) return false;
var blob = user.GetBlob() ?? new UserBlob();
blob.InvitationToken = token;
user.SetBlob(blob);
await userManager.UpdateAsync(user);
return true;
}
public static async Task<ApplicationUser?> FindByInvitationTokenAsync<TUser>(this UserManager<ApplicationUser> userManager, string userId, string token) where TUser : class
{
var user = await userManager.FindByIdAsync(userId);
var isValid = user is not null && (
user.GetBlob()?.InvitationToken == token ||
// backwards-compatibility with old tokens
await userManager.VerifyUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose, token));
return isValid ? user : null; return isValid ? user : null;
} }
} }

View File

@@ -1,6 +1,9 @@
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Controllers.RegisterFromAdminViewModel @model BTCPayServer.Controllers.RegisterFromAdminViewModel
@{ @{
ViewData.SetActivePage(ServerNavPages.Users, "Create account"); ViewData.SetActivePage(ServerNavPages.Users, "Create account");
var canSendEmail = ViewData["CanSendEmail"] is true;
} }
<form method="post" asp-action="CreateUser"> <form method="post" asp-action="CreateUser">
@@ -55,6 +58,17 @@
<span asp-validation-for="EmailConfirmed" class="text-danger"></span> <span asp-validation-for="EmailConfirmed" class="text-danger"></span>
</div> </div>
} }
<div class="d-flex my-3">
<input asp-for="SendInvitationEmail" type="checkbox" class="btcpay-toggle me-3" disabled="@(canSendEmail ? null : "disabled")" />
<div>
<label asp-for="SendInvitationEmail" class="form-check-label"></label>
<span asp-validation-for="SendInvitationEmail" class="text-danger"></span>
@if (!canSendEmail)
{
<div class="text-secondary">Your email server has not been configured. <a asp-controller="UIServer" asp-action="Emails">Please configure it first.</a></div>
}
</div>
</div>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -55,6 +55,7 @@
{ Disabled: true } => ("Disabled", "danger"), { Disabled: true } => ("Disabled", "danger"),
{ Approved: false } => ("Pending Approval", "warning"), { Approved: false } => ("Pending Approval", "warning"),
{ EmailConfirmed: false } => ("Pending Email Verification", "warning"), { EmailConfirmed: false } => ("Pending Email Verification", "warning"),
{ InvitationUrl: not null } => ("Pending Invitation", "warning"),
_ => ("Active", "success") _ => ("Active", "success")
}; };
var detailsId = $"user_details_{user.Id}"; var detailsId = $"user_details_{user.Id}";
@@ -68,7 +69,7 @@
} }
</div> </div>
</td> </td>
<td>@user.Stores.Count() Store@(user.Stores.Count() == 1 ? "" : "s")</td> <td class="@(user.Stores.Any() ? null : "text-muted")">@user.Stores.Count() Store@(user.Stores.Count() == 1 ? "" : "s")</td>
<td>@user.Created?.ToBrowserDate()</td> <td>@user.Created?.ToBrowserDate()</td>
<td> <td>
<span class="user-status badge bg-@status.Item2">@status.Item1</span> <span class="user-status badge bg-@status.Item2">@status.Item1</span>
@@ -82,11 +83,11 @@
{ {
<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> <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>
} }
<a asp-action="User" asp-route-userId="@user.Id" class="user-edit">Edit</a>
@if (status.Item2 != "warning") @if (status.Item2 != "warning")
{ {
<a asp-action="ToggleUser" asp-route-userId="@user.Id" asp-route-enable="@user.Disabled">@(user.Disabled ? "Enable" : "Disable")</a> <a asp-action="ToggleUser" asp-route-userId="@user.Id" asp-route-enable="@user.Disabled">@(user.Disabled ? "Enable" : "Disable")</a>
} }
<a asp-action="User" asp-route-userId="@user.Id" class="user-edit">Edit</a>
<a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a> <a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a>
</div> </div>
</td> </td>
@@ -100,7 +101,21 @@
</tr> </tr>
<tr id="@detailsId" class="user-details-row collapse"> <tr id="@detailsId" class="user-details-row collapse">
<td colspan="6" class="border-top-0"> <td colspan="6" class="border-top-0">
@if (user.Stores.Any()) @if (!string.IsNullOrEmpty(user.InvitationUrl))
{
<div class="payment-box m-0">
<div class="qr-container">
<vc:qr-code data="@user.InvitationUrl" />
</div>
<div class="input-group mt-3">
<div class="form-floating">
<vc:truncate-center text="@user.InvitationUrl" padding="15" elastic="true" classes="form-control-plaintext"/>
<label>Invitation URL</label>
</div>
</div>
</div>
}
else if (user.Stores.Any())
{ {
<ul class="mb-0 p-0"> <ul class="mb-0 p-0">
@foreach (var store in user.Stores) @foreach (var store in user.Stores)
@@ -118,7 +133,7 @@
} }
else else
{ {
<span class="text-secondary">No stores</span> <span class="text-secondary">No stores</span>
} }
</td> </td>
</tr> </tr>

View File

@@ -21,6 +21,22 @@
<button id="page-primary" name="command" type="submit" class="btn btn-primary" value="Save">Save</button> <button id="page-primary" name="command" type="submit" class="btn btn-primary" value="Save">Save</button>
</div> </div>
<partial name="_StatusMessage" /> <partial name="_StatusMessage" />
@if (!string.IsNullOrEmpty(Model.InvitationUrl))
{
<div class="payment-box mx-0 mb-5">
<div class="qr-container">
<vc:qr-code data="@Model.InvitationUrl" />
</div>
<div class="input-group mt-3">
<div class="form-floating">
<vc:truncate-center text="@Model.InvitationUrl" padding="15" elastic="true" classes="form-control-plaintext" id="InvitationUrl"/>
<label for="InvitationUrl">Invitation URL</label>
</div>
</div>
</div>
}
<div class="form-group"> <div class="form-group">
<label asp-for="Name" class="form-label"></label> <label asp-for="Name" class="form-label"></label>
<input asp-for="Name" class="form-control" /> <input asp-for="Name" class="form-control" />