Admins can approve registered users (#5647)

* Users list: Cleanups

* Policies: Flip registration settings

* Policies: Add RequireUserApproval setting

* Add approval to user

* Require approval on login and for API key

* API handling

* AccountController cleanups

* Test fix

* Apply suggestions from code review

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>

* Add missing imports

* Communicate login requirements to user on account creation

* Add login requirements to basic auth handler

* Cleanups and test fix

* Encapsulate approval logic in user service and log approval changes

* Send follow up "Account approved" email

Closes #5656.

* Add notification for admins

* Fix creating a user via the admin view

* Update list: Unify flags into status column, add approve action

* Adjust "Resend email" wording

* Incorporate feedback from code review

* Remove duplicate test server policy reset

---------

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
This commit is contained in:
d11n 2024-01-31 06:45:54 +01:00 committed by GitHub
parent 411e0334d0
commit 6290b0f3bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1010 additions and 353 deletions

View file

@ -41,6 +41,14 @@ namespace BTCPayServer.Client
return response.IsSuccessStatusCode;
}
public virtual async Task<bool> ApproveUser(string idOrEmail, bool approved, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/{idOrEmail}/approve", null,
new ApproveUserRequest { Approved = approved }, HttpMethod.Post), token);
await HandleResponse(response);
return response.IsSuccessStatusCode;
}
public virtual async Task<ApplicationUserData[]> GetUsers(CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token);

View file

@ -25,6 +25,16 @@ namespace BTCPayServer.Client.Models
/// </summary>
public bool RequiresEmailConfirmation { get; set; }
/// <summary>
/// Whether the user was approved by an admin
/// </summary>
public bool Approved { get; set; }
/// <summary>
/// whether the user needed approval on account creation
/// </summary>
public bool RequiresApproval { get; set; }
/// <summary>
/// the roles of the user
/// </summary>

View file

@ -0,0 +1,6 @@
namespace BTCPayServer.Client;
public class ApproveUserRequest
{
public bool Approved { get; set; }
}

View file

@ -11,6 +11,8 @@ namespace BTCPayServer.Data
public class ApplicationUser : IdentityUser, IHasBlob<UserBlob>
{
public bool RequiresEmailConfirmation { get; set; }
public bool RequiresApproval { get; set; }
public bool Approved { get; set; }
public List<StoredFile> StoredFiles { get; set; }
[Obsolete("U2F support has been replace with FIDO2")]
public List<U2FDevice> U2FDevices { get; set; }

View file

@ -0,0 +1,39 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240104155620_AddApprovalToApplicationUser")]
public partial class AddApprovalToApplicationUser : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Approved",
table: "AspNetUsers",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "RequiresApproval",
table: "AspNetUsers",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Approved",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "RequiresApproval",
table: "AspNetUsers");
}
}
}

View file

@ -112,6 +112,9 @@ namespace BTCPayServer.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<bool>("Approved")
.HasColumnType("INTEGER");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
@ -158,6 +161,9 @@ namespace BTCPayServer.Migrations
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("RequiresApproval")
.HasColumnType("INTEGER");
b.Property<bool>("RequiresEmailConfirmation")
.HasColumnType("INTEGER");

View file

@ -245,6 +245,9 @@ namespace BTCPayServer.Tests
rateProvider.Providers.Add("kraken", kraken);
}
// reset test server policies
var settings = GetService<SettingsRepository>();
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = false });
TestLogs.LogInformation("Waiting site is operational...");
await WaitSiteIsOperational();

View file

@ -694,14 +694,10 @@ namespace BTCPayServer.Tests
// Try loading 1 user by email. Loading myself.
await AssertHttpError(403, async () => await badClient.GetUserByIdOrEmail(badUser.Email));
// Why is this line needed? I saw it in "CanDeleteUsersViaApi" as well. Is this part of the cleanup?
tester.Stores.Remove(adminUser.StoreId);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanCreateUsersViaAPI()
@ -3571,6 +3567,78 @@ namespace BTCPayServer.Tests
await newUserBasicClient.GetCurrentUser();
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
public async Task ApproveUserTests()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var admin = tester.NewAccount();
await admin.GrantAccessAsync(true);
var adminClient = await admin.CreateClient(Policies.Unrestricted);
Assert.False((await adminClient.GetUserByIdOrEmail(admin.UserId)).RequiresApproval);
Assert.Empty(await adminClient.GetNotifications());
// require approval
var settings = tester.PayTester.GetService<SettingsRepository>();
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = true });
// new user needs approval
var unapprovedUser = tester.NewAccount();
await unapprovedUser.GrantAccessAsync();
var unapprovedUserBasicAuthClient = await unapprovedUser.CreateClient();
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserBasicAuthClient.GetCurrentUser();
});
var unapprovedUserApiKeyClient = await unapprovedUser.CreateClient(Policies.Unrestricted);
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserApiKeyClient.GetCurrentUser();
});
Assert.True((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).RequiresApproval);
Assert.False((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
Assert.Single(await adminClient.GetNotifications(false));
// approve
Assert.True(await adminClient.ApproveUser(unapprovedUser.UserId, true, CancellationToken.None));
Assert.True((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
Assert.True((await unapprovedUserApiKeyClient.GetCurrentUser()).Approved);
Assert.True((await unapprovedUserBasicAuthClient.GetCurrentUser()).Approved);
// un-approve
Assert.True(await adminClient.ApproveUser(unapprovedUser.UserId, false, CancellationToken.None));
Assert.False((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserApiKeyClient.GetCurrentUser();
});
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserBasicAuthClient.GetCurrentUser();
});
// reset policies to not require approval
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = false });
// new user does not need approval
var newUser = tester.NewAccount();
await newUser.GrantAccessAsync();
var newUserBasicAuthClient = await newUser.CreateClient();
var newUserApiKeyClient = await newUser.CreateClient(Policies.Unrestricted);
Assert.False((await newUserApiKeyClient.GetCurrentUser()).RequiresApproval);
Assert.False((await newUserApiKeyClient.GetCurrentUser()).Approved);
Assert.False((await newUserBasicAuthClient.GetCurrentUser()).RequiresApproval);
Assert.False((await newUserBasicAuthClient.GetCurrentUser()).Approved);
Assert.Single(await adminClient.GetNotifications(false));
// try unapproving user which does not have the RequiresApproval flag
await AssertAPIError("invalid-state", async () =>
{
await adminClient.ApproveUser(newUser.UserId, false, CancellationToken.None);
});
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
[Trait("Lightning", "Lightning")]

View file

@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning;
using BTCPayServer.Services;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
@ -76,6 +77,7 @@ namespace BTCPayServer.Tests
// A bit less than test timeout
TimeSpan.FromSeconds(50));
}
ServerUri = Server.PayTester.ServerUri;
Driver.Manage().Window.Maximize();

View file

@ -405,6 +405,148 @@ namespace BTCPayServer.Tests
Assert.Contains("/login", s.Driver.Url);
}
[Fact(Timeout = TestTimeout)]
public async Task CanRequireApprovalForNewAccounts()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
var settings = s.Server.PayTester.GetService<SettingsRepository>();
var policies = await settings.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
Assert.True(policies.EnableRegistration);
Assert.False(policies.RequiresUserApproval);
// Register admin and adapt policies
s.RegisterNewUser(true);
var admin = s.AsTestAccount();
s.GoToHome();
s.GoToServer(ServerNavPages.Policies);
Assert.True(s.Driver.FindElement(By.Id("EnableRegistration")).Selected);
Assert.False(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
s.Driver.FindElement(By.Id("RequiresUserApproval")).Click();
s.Driver.FindElement(By.Id("SaveButton")).Click();
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();
// Register user and try to log in
s.GoToRegister();
s.RegisterNewUser();
s.Driver.AssertNoError();
Assert.Contains("Account created. The new account requires approval by an admin before you can log in", s.FindAlertMessage().Text);
Assert.Contains("/login", s.Driver.Url);
var unapproved = s.AsTestAccount();
s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password);
Assert.Contains("Your user account requires approval by an admin before you can log in", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning).Text);
Assert.Contains("/login", s.Driver.Url);
// Login with admin
s.GoToLogin();
s.LogIn(admin.RegisterDetails.Email, admin.RegisterDetails.Password);
s.GoToHome();
// Check notification
TestUtils.Eventually(() => Assert.Equal("1", s.Driver.FindElement(By.Id("NotificationsBadge")).Text));
s.Driver.FindElement(By.Id("NotificationsHandle")).Click();
Assert.Matches($"New user {unapproved.RegisterDetails.Email} requires approval", s.Driver.FindElement(By.CssSelector("#NotificationsList .notification")).Text);
s.Driver.FindElement(By.Id("NotificationsMarkAllAsSeen")).Click();
// Reset approval policy
s.GoToServer(ServerNavPages.Policies);
Assert.True(s.Driver.FindElement(By.Id("EnableRegistration")).Selected);
Assert.True(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
s.Driver.FindElement(By.Id("RequiresUserApproval")).Click();
s.Driver.FindElement(By.Id("SaveButton")).Click();
Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text);
Assert.False(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
// Check user create view does not have approval checkbox
s.GoToServer(ServerNavPages.Users);
s.Driver.FindElement(By.Id("CreateUser")).Click();
s.Driver.ElementDoesNotExist(By.Id("Approved"));
s.Logout();
// Still requires approval for user who registered before
s.GoToLogin();
s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password);
Assert.Contains("Your user account requires approval by an admin before you can log in", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning).Text);
Assert.Contains("/login", s.Driver.Url);
// New user can register and gets in without approval
s.GoToRegister();
s.RegisterNewUser();
s.Driver.AssertNoError();
Assert.DoesNotContain("/login", s.Driver.Url);
var autoApproved = s.AsTestAccount();
s.CreateNewStore();
s.Logout();
// Login with admin and check list
s.GoToLogin();
s.LogIn(admin.RegisterDetails.Email, admin.RegisterDetails.Password);
s.GoToHome();
// No notification this time
s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge"));
// Check users list
s.GoToServer(ServerNavPages.Users);
var rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
Assert.True(rows.Count >= 3);
// Check user which didn't require approval
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(autoApproved.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
Assert.Single(rows);
Assert.Contains(autoApproved.RegisterDetails.Email, rows.First().Text);
s.Driver.ElementDoesNotExist(By.CssSelector("#UsersList tr:first-child .user-approved"));
// Edit view does not contain approve toggle
s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-edit")).Click();
s.Driver.ElementDoesNotExist(By.Id("Approved"));
// Check user which still requires approval
s.GoToServer(ServerNavPages.Users);
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(unapproved.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
Assert.Single(rows);
Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text);
Assert.Contains("Pending Approval", s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-status")).Text);
// Approve user
s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-edit")).Click();
s.Driver.FindElement(By.Id("Approved")).Click();
s.Driver.FindElement(By.Id("SaveUser")).Click();
Assert.Contains("User successfully updated", s.FindAlertMessage().Text);
// Check list again
s.GoToServer(ServerNavPages.Users);
Assert.Contains(unapproved.RegisterDetails.Email, s.Driver.FindElement(By.Id("SearchTerm")).GetAttribute("value"));
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
Assert.Single(rows);
Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text);
Assert.Contains("Active", s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-status")).Text);
// Finally, login user that needed approval
s.Logout();
s.GoToLogin();
s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password);
s.Driver.AssertNoError();
Assert.DoesNotContain("/login", s.Driver.Url);
s.CreateNewStore();
}
[Fact(Timeout = TestTimeout)]
public async Task CanUseSSHService()
{

View file

@ -2325,17 +2325,21 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester(newDb: true);
await tester.StartAsync();
var f = tester.PayTester.GetService<ApplicationDbContextFactory>();
const string id = "BTCPayServer.Services.PoliciesSettings";
using (var ctx = f.CreateContext())
{
var setting = new SettingData() { Id = "BTCPayServer.Services.PoliciesSettings" };
setting.Value = JObject.Parse("{\"RootAppId\": null, \"RootAppType\": 1, \"Experimental\": false, \"PluginSource\": null, \"LockSubscription\": false, \"DisableSSHService\": false, \"PluginPreReleases\": false, \"BlockExplorerLinks\": [],\"DomainToAppMapping\": [{\"AppId\": \"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\", \"Domain\": \"donate.nicolas-dorier.com\", \"AppType\": 0}], \"CheckForNewVersions\": false, \"AllowHotWalletForAll\": false, \"RequiresConfirmedEmail\": false, \"DiscourageSearchEngines\": false, \"DisableInstantNotifications\": false, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString();
// remove existing policies setting
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == id);
if (setting != null) ctx.Settings.Remove(setting);
// create legacy policies setting that needs migration
setting = new SettingData { Id = id, Value = JObject.Parse("{\"RootAppId\": null, \"RootAppType\": 1, \"Experimental\": false, \"PluginSource\": null, \"LockSubscription\": false, \"DisableSSHService\": false, \"PluginPreReleases\": false, \"BlockExplorerLinks\": [],\"DomainToAppMapping\": [{\"AppId\": \"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\", \"Domain\": \"donate.nicolas-dorier.com\", \"AppType\": 0}], \"CheckForNewVersions\": false, \"AllowHotWalletForAll\": false, \"RequiresConfirmedEmail\": false, \"DiscourageSearchEngines\": false, \"DisableInstantNotifications\": false, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString() };
ctx.Settings.Add(setting);
await ctx.SaveChangesAsync();
}
await RestartMigration(tester);
using (var ctx = f.CreateContext())
{
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == "BTCPayServer.Services.PoliciesSettings");
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == id);
var o = JObject.Parse(setting.Value);
Assert.Equal("Crowdfund", o["RootAppType"].Value<string>());
o = (JObject)((JArray)o["DomainToAppMapping"])[0];

View file

@ -92,6 +92,26 @@ namespace BTCPayServer.Controllers.Greenfield
$"{(request.Locked ? "Locking" : "Unlocking")} user failed");
}
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/users/{idOrEmail}/approve")]
public async Task<IActionResult> ApproveUser(string idOrEmail, ApproveUserRequest request)
{
var user = await _userManager.FindByIdOrEmail(idOrEmail);
if (user is null)
{
return this.UserNotFound();
}
var success = false;
if (user.RequiresApproval)
{
success = await _userService.SetUserApproval(user.Id, request.Approved, Request.GetAbsoluteRootUri());
}
return success ? Ok() : this.CreateAPIError("invalid-state",
$"{(request.Approved ? "Approving" : "Unapproving")} user failed");
}
[Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/users/")]
public async Task<ActionResult<ApplicationUserData[]>> GetUsers()
@ -163,7 +183,9 @@ namespace BTCPayServer.Controllers.Greenfield
UserName = request.Email,
Email = request.Email,
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
RequiresApproval = policies.RequiresUserApproval,
Created = DateTimeOffset.UtcNow,
Approved = !anyAdmin && isAdmin // auto-approve first admin
};
var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password);
if (!passwordValidation.Succeeded)

View file

@ -1084,6 +1084,13 @@ namespace BTCPayServer.Controllers.Greenfield
new LockUserRequest { Locked = disabled }));
}
public override async Task<bool> ApproveUser(string idOrEmail, bool approved, CancellationToken token = default)
{
return GetFromActionResult<bool>(
await GetController<GreenfieldUsersController>().ApproveUser(idOrEmail,
new ApproveUserRequest { Approved = approved }));
}
public override async Task<OnChainWalletTransactionData> PatchOnChainWalletTransaction(string storeId,
string cryptoCode, string transactionId,
PatchOnChainTransactionRequest request, bool force = false, CancellationToken token = default)

View file

@ -2,7 +2,6 @@ using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
@ -109,6 +108,7 @@ namespace BTCPayServer.Controllers
{
if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl))
return RedirectToLocal();
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
@ -118,15 +118,13 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
return View(nameof(Login), new LoginViewModel() { Email = email });
return View(nameof(Login), new LoginViewModel { Email = email });
}
[HttpPost("/login/code")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
[RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> LoginWithCode(string loginCode, string returnUrl = null)
{
if (!string.IsNullOrEmpty(loginCode))
@ -134,17 +132,26 @@ namespace BTCPayServer.Controllers
var userId = _userLoginCodeService.Verify(loginCode);
if (userId is null)
{
ModelState.AddModelError(string.Empty,
"Login code was invalid");
return await Login(returnUrl, null);
TempData[WellKnownTempData.ErrorMessage] = "Login code was invalid";
return await Login(returnUrl);
}
var user = await _userManager.FindByIdAsync(userId);
_logger.LogInformation("User with ID {UserId} logged in with a login code.", user.Id);
var user = await _userManager.FindByIdAsync(userId);
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return await Login(returnUrl);
}
_logger.LogInformation("User with ID {UserId} logged in with a login code", user!.Id);
await _signInManager.SignInAsync(user, false, "LoginCode");
return RedirectToLocal(returnUrl);
}
return await Login(returnUrl, null);
return await Login(returnUrl);
}
[HttpPost("/login")]
@ -161,24 +168,20 @@ namespace BTCPayServer.Controllers
ViewData["ReturnUrl"] = returnUrl;
if (ModelState.IsValid)
{
// Require the user to have a confirmed email before they can log on.
// Require the user to pass basic checks (approval, confirmed email, not disabled) before they can log on
var user = await _userManager.FindByEmailAsync(model.Email);
if (user != null)
const string errorMessage = "Invalid login attempt.";
if (!UserService.TryCanLogin(user, out var message))
{
if (user.RequiresEmailConfirmation && !await _userManager.IsEmailConfirmedAsync(user))
TempData.SetStatusMessageModel(new StatusMessageModel
{
ModelState.AddModelError(string.Empty,
"You must have a confirmed email to log in.");
return View(model);
}
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return View(model);
}
var fido2Devices = await _fido2Service.HasCredentials(user.Id);
var fido2Devices = await _fido2Service.HasCredentials(user!.Id);
var lnurlAuthCredentials = await _lnurlAuthService.HasCredentials(user.Id);
if (!await _userManager.IsLockedOutAsync(user) && (fido2Devices || lnurlAuthCredentials))
{
@ -196,33 +199,30 @@ namespace BTCPayServer.Controllers
};
}
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = twoFModel,
LoginWithFido2ViewModel = fido2Devices ? await BuildFido2ViewModel(model.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = lnurlAuthCredentials ? await BuildLNURLAuthViewModel(model.RememberMe, user) : null,
});
}
else
{
await _userManager.AccessFailedAsync(user);
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
await _userManager.AccessFailedAsync(user);
ModelState.AddModelError(string.Empty, errorMessage!);
return View(model);
}
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
_logger.LogInformation($"User '{user.Id}' logged in.");
_logger.LogInformation("User {UserId} logged in", user.Id);
return RedirectToLocal(returnUrl);
}
if (result.RequiresTwoFactor)
{
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = new LoginWith2faViewModel()
LoginWith2FaViewModel = new LoginWith2faViewModel
{
RememberMe = model.RememberMe
}
@ -230,14 +230,12 @@ namespace BTCPayServer.Controllers
}
if (result.IsLockedOut)
{
_logger.LogWarning($"User '{user.Id}' account locked out.");
_logger.LogWarning("User {UserId} account locked out", user.Id);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
ModelState.AddModelError(string.Empty, errorMessage);
return View(model);
}
// If we got this far, something failed, redisplay form
@ -253,7 +251,7 @@ namespace BTCPayServer.Controllers
{
return null;
}
return new LoginWithFido2ViewModel()
return new LoginWithFido2ViewModel
{
Data = r,
UserId = user.Id,
@ -263,7 +261,6 @@ namespace BTCPayServer.Controllers
return null;
}
private async Task<LoginWithLNURLAuthViewModel> BuildLNURLAuthViewModel(bool rememberMe, ApplicationUser user)
{
if (_btcPayServerEnvironment.IsSecure(HttpContext))
@ -273,15 +270,14 @@ namespace BTCPayServer.Controllers
{
return null;
}
return new LoginWithLNURLAuthViewModel()
return new LoginWithLNURLAuthViewModel
{
RememberMe = rememberMe,
UserId = user.Id,
LNURLEndpoint = new Uri(_linkGenerator.GetUriByAction(
action: nameof(UILNURLAuthController.LoginResponse),
controller: "UILNURLAuth",
values: new { userId = user.Id, action = "login", tag = "login", k1 = Encoders.Hex.EncodeData(r) }, Request.Scheme, Request.Host, Request.PathBase))
action: nameof(UILNURLAuthController.LoginResponse),
controller: "UILNURLAuth",
values: new { userId = user.Id, action = "login", tag = "login", k1 = Encoders.Hex.EncodeData(r) }, Request.Scheme, Request.Host, Request.PathBase) ?? string.Empty)
};
}
return null;
@ -298,14 +294,18 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
var errorMessage = "Invalid login attempt.";
var user = await _userManager.FindByIdAsync(viewModel.UserId);
if (user == null)
if (!UserService.TryCanLogin(user, out var message))
{
return NotFound();
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return RedirectToAction("Login");
}
var errorMessage = string.Empty;
try
{
var k1 = Encoders.Hex.DecodeData(viewModel.LNURLEndpoint.ParseQueryString().Get("k1"));
@ -313,34 +313,33 @@ namespace BTCPayServer.Controllers
storedk1.SequenceEqual(k1))
{
_lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out _);
await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in.");
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in");
return RedirectToLocal(returnUrl);
}
errorMessage = "Invalid login attempt.";
}
catch (Exception e)
{
errorMessage = e.Message;
}
ModelState.AddModelError(string.Empty, errorMessage);
return View("SecondaryLogin", new SecondaryLoginViewModel()
if (!string.IsNullOrEmpty(errorMessage))
{
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
ModelState.AddModelError(string.Empty, errorMessage);
}
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user!.Id) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = viewModel,
LoginWith2FaViewModel = !user.TwoFactorEnabled
? null
: new LoginWith2faViewModel()
: new LoginWith2faViewModel
{
RememberMe = viewModel.RememberMe
}
});
}
[HttpPost("/login/fido2")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
@ -352,44 +351,50 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
var errorMessage = "Invalid login attempt.";
var user = await _userManager.FindByIdAsync(viewModel.UserId);
if (user == null)
if (!UserService.TryCanLogin(user, out var message))
{
return NotFound();
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return RedirectToAction("Login");
}
var errorMessage = string.Empty;
try
{
if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>()))
{
await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in.");
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in");
return RedirectToLocal(returnUrl);
}
errorMessage = "Invalid login attempt.";
}
catch (Fido2VerificationException e)
{
errorMessage = e.Message;
}
ModelState.AddModelError(string.Empty, errorMessage);
if (!string.IsNullOrEmpty(errorMessage))
{
ModelState.AddModelError(string.Empty, errorMessage);
}
viewModel.Response = null;
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWithFido2ViewModel = viewModel,
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user!.Id) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
LoginWith2FaViewModel = !user.TwoFactorEnabled
? null
: new LoginWith2faViewModel()
: new LoginWith2faViewModel
{
RememberMe = viewModel.RememberMe
}
});
}
[HttpGet("/login/2fa")]
[AllowAnonymous]
public async Task<IActionResult> LoginWith2fa(bool rememberMe, string returnUrl = null)
@ -401,7 +406,6 @@ namespace BTCPayServer.Controllers
// Ensure the user has gone through the username & password screen first
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new ApplicationException($"Unable to load two-factor authentication user.");
@ -409,11 +413,11 @@ namespace BTCPayServer.Controllers
ViewData["ReturnUrl"] = returnUrl;
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe },
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
@ -437,32 +441,32 @@ namespace BTCPayServer.Controllers
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return View(model);
}
var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty, StringComparison.InvariantCulture).Replace("-", string.Empty, StringComparison.InvariantCulture);
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
if (result.Succeeded)
{
_logger.LogInformation("User with ID {UserId} logged in with 2fa.", user.Id);
_logger.LogInformation("User with ID {UserId} logged in with 2fa", user.Id);
return RedirectToLocal(returnUrl);
}
else if (result.IsLockedOut)
_logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}", user.Id);
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
return View("SecondaryLogin", new SecondaryLoginViewModel
{
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
}
else
{
_logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}.", user.Id);
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
return View("SecondaryLogin", new SecondaryLoginViewModel()
{
LoginWith2FaViewModel = model,
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
LoginWith2FaViewModel = model,
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
[HttpGet("/login/recovery-code")]
@ -504,30 +508,35 @@ namespace BTCPayServer.Controllers
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new ApplicationException($"Unable to load two-factor authentication user.");
throw new ApplicationException("Unable to load two-factor authentication user.");
}
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return View(model);
}
var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty, StringComparison.InvariantCulture);
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
if (result.Succeeded)
{
_logger.LogInformation("User with ID {UserId} logged in with a recovery code.", user.Id);
_logger.LogInformation("User with ID {UserId} logged in with a recovery code", user.Id);
return RedirectToLocal(returnUrl);
}
if (result.IsLockedOut)
{
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
_logger.LogWarning("User with ID {UserId} account locked out", user.Id);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
}
else
{
_logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id);
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return View();
}
_logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id);
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return View();
}
[HttpGet("/login/lockout")]
@ -540,7 +549,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/register")]
[AllowAnonymous]
[RateLimitsFilter(ZoneLimits.Register, Scope = RateLimitsScope.RemoteAddress)]
public IActionResult Register(string returnUrl = null, bool logon = true)
public IActionResult Register(string returnUrl = null)
{
if (!CanLoginOrRegister())
{
@ -567,32 +576,36 @@ namespace BTCPayServer.Controllers
var policies = await _SettingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
if (policies.LockSubscription && !User.IsInRole(Roles.ServerAdmin))
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
if (ModelState.IsValid)
{
var anyAdmin = (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any();
var isFirstAdmin = !anyAdmin || (model.IsAdmin && _Options.CheatMode);
var user = new ApplicationUser
{
UserName = model.Email,
Email = model.Email,
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
Created = DateTimeOffset.UtcNow
RequiresApproval = policies.RequiresUserApproval,
Created = DateTimeOffset.UtcNow,
Approved = isFirstAdmin // auto-approve first admin
};
var result = await _userManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
var admin = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
if (admin.Count == 0 || (model.IsAdmin && _Options.CheatMode))
if (isFirstAdmin)
{
await _RoleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin));
await _userManager.AddToRoleAsync(user, Roles.ServerAdmin);
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>();
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
settings.FirstRun = false;
await _SettingsRepository.UpdateSetting<ThemeSettings>(settings);
await _SettingsRepository.UpdateSetting(settings);
await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs);
RegisteredAdmin = true;
}
_eventAggregator.Publish(new UserRegisteredEvent()
_eventAggregator.Publish(new UserRegisteredEvent
{
RequestUri = Request.GetAbsoluteRootUri(),
User = user,
@ -600,19 +613,29 @@ namespace BTCPayServer.Controllers
});
RegisteredUserId = user.Id;
if (!policies.RequiresConfirmedEmail)
TempData[WellKnownTempData.SuccessMessage] = "Account created.";
if (policies.RequiresConfirmedEmail)
{
if (logon)
await _signInManager.SignInAsync(user, isPersistent: false);
TempData[WellKnownTempData.SuccessMessage] += " Please confirm your email.";
}
if (policies.RequiresUserApproval)
{
TempData[WellKnownTempData.SuccessMessage] += " The new account requires approval by an admin before you can log in.";
}
if (policies.RequiresConfirmedEmail || policies.RequiresUserApproval)
{
return RedirectToAction(nameof(Login));
}
if (logon)
{
await _signInManager.SignInAsync(user, isPersistent: false);
return RedirectToLocal(returnUrl);
}
else
{
TempData[WellKnownTempData.SuccessMessage] = "Account created, please confirm your email";
return View();
}
}
AddErrors(result);
else
{
AddErrors(result);
}
}
// If we got this far, something failed, redisplay form
@ -628,8 +651,8 @@ namespace BTCPayServer.Controllers
{
await _signInManager.SignOutAsync();
HttpContext.DeleteUserPrefsCookie();
_logger.LogInformation("User logged out.");
return RedirectToAction(nameof(UIAccountController.Login));
_logger.LogInformation("User logged out");
return RedirectToAction(nameof(Login));
}
[HttpGet("/register/confirm-email")]
@ -650,7 +673,7 @@ namespace BTCPayServer.Controllers
if (!await _userManager.HasPasswordAsync(user))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Info,
Message = "Your email has been confirmed but you still need to set your password."
@ -660,7 +683,7 @@ namespace BTCPayServer.Controllers
if (result.Succeeded)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Your email has been confirmed."
@ -687,12 +710,12 @@ namespace BTCPayServer.Controllers
if (ModelState.IsValid)
{
var user = await _userManager.FindByEmailAsync(model.Email);
if (user == null || (user.RequiresEmailConfirmation && !(await _userManager.IsEmailConfirmedAsync(user))))
if (!UserService.TryCanLogin(user, out _))
{
// Don't reveal that the user does not exist or is not confirmed
return RedirectToAction(nameof(ForgotPasswordConfirmation));
}
_eventAggregator.Publish(new UserPasswordResetRequestedEvent()
_eventAggregator.Publish(new UserPasswordResetRequestedEvent
{
User = user,
RequestUri = Request.GetAbsoluteRootUri()
@ -740,16 +763,16 @@ namespace BTCPayServer.Controllers
return View(model);
}
var user = await _userManager.FindByEmailAsync(model.Email);
if (user == null)
if (!UserService.TryCanLogin(user, out _))
{
// Don't reveal that the user does not exist
return RedirectToAction(nameof(Login));
}
var result = await _userManager.ResetPasswordAsync(user, model.Code, model.Password);
var result = await _userManager.ResetPasswordAsync(user!, model.Code, model.Password);
if (result.Succeeded)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Password successfully set."
@ -800,7 +823,7 @@ namespace BTCPayServer.Controllers
private void SetInsecureFlags()
{
TempData.SetStatusMessageModel(new StatusMessageModel()
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "You cannot login over an insecure connection. Please use HTTPS or Tor."

View file

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@ -8,25 +7,21 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using MimeKit;
namespace BTCPayServer.Controllers
{
public partial class UIServerController
{
[Route("server/users")]
[HttpGet("server/users")]
public async Task<IActionResult> ListUsers(
[FromServices] RoleManager<IdentityRole> roleManager,
UsersViewModel model,
string sortOrder = null
)
UsersViewModel model,
string sortOrder = null)
{
model = this.ParseListQuery(model ?? new UsersViewModel());
@ -64,7 +59,8 @@ namespace BTCPayServer.Controllers
Name = u.UserName,
Email = u.Email,
Id = u.Id,
Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation,
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
@ -74,44 +70,67 @@ namespace BTCPayServer.Controllers
return View(model);
}
[Route("server/users/{userId}")]
[HttpGet("server/users/{userId}")]
public new async Task<IActionResult> User(string userId)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var roles = await _UserManager.GetRolesAsync(user);
var userVM = new UsersViewModel.UserViewModel
var model = new UsersViewModel.UserViewModel
{
Id = user.Id,
Email = user.Email,
Verified = user.EmailConfirmed || !user.RequiresEmailConfirmation,
EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
Approved = user.RequiresApproval ? user.Approved : null,
IsAdmin = Roles.HasServerAdmin(roles)
};
return View(userVM);
return View(model);
}
[Route("server/users/{userId}")]
[HttpPost]
[HttpPost("server/users/{userId}")]
public new async Task<IActionResult> User(string userId, UsersViewModel.UserViewModel viewModel)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
bool? propertiesChanged = null;
bool? adminStatusChanged = null;
bool? approvalStatusChanged = null;
if (user.RequiresApproval && viewModel.Approved.HasValue)
{
approvalStatusChanged = await _userService.SetUserApproval(user.Id, viewModel.Approved.Value, Request.GetAbsoluteRootUri());
}
if (user.RequiresEmailConfirmation && viewModel.EmailConfirmed.HasValue && user.EmailConfirmed != viewModel.EmailConfirmed)
{
user.EmailConfirmed = viewModel.EmailConfirmed.Value;
propertiesChanged = true;
}
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var roles = await _UserManager.GetRolesAsync(user);
var wasAdmin = Roles.HasServerAdmin(roles);
if (!viewModel.IsAdmin && admins.Count == 1 && wasAdmin)
{
TempData[WellKnownTempData.ErrorMessage] = "This is the only Admin, so their role can't be removed until another Admin is added.";
return View(viewModel); // return
return View(viewModel);
}
if (viewModel.IsAdmin != wasAdmin)
{
var success = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin);
if (success)
adminStatusChanged = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin);
}
if (propertiesChanged is true)
{
propertiesChanged = await _UserManager.UpdateAsync(user) is { Succeeded: true };
}
if (propertiesChanged.HasValue || adminStatusChanged.HasValue || approvalStatusChanged.HasValue)
{
if (propertiesChanged is not false && adminStatusChanged is not false && approvalStatusChanged is not false)
{
TempData[WellKnownTempData.SuccessMessage] = "User successfully updated";
}
@ -121,23 +140,22 @@ namespace BTCPayServer.Controllers
}
}
return RedirectToAction(nameof(User), new { userId = userId });
return RedirectToAction(nameof(User), new { userId });
}
[Route("server/users/new")]
[HttpGet]
[HttpGet("server/users/new")]
public IActionResult CreateUser()
{
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
return View();
}
[Route("server/users/new")]
[HttpPost]
[HttpPost("server/users/new")]
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
{
var requiresConfirmedEmail = _policiesSettings.RequiresConfirmedEmail;
ViewData["AllowRequestEmailConfirmation"] = requiresConfirmedEmail;
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
if (!_Options.CheatMode)
model.IsAdmin = false;
if (ModelState.IsValid)
@ -148,7 +166,9 @@ namespace BTCPayServer.Controllers
UserName = model.Email,
Email = model.Email,
EmailConfirmed = model.EmailConfirmed,
RequiresEmailConfirmation = requiresConfirmedEmail,
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
RequiresApproval = _policiesSettings.RequiresUserApproval,
Approved = model.Approved,
Created = DateTimeOffset.UtcNow
};
@ -223,7 +243,6 @@ namespace BTCPayServer.Controllers
{
if (await _userService.IsUserTheOnlyOneAdmin(user))
{
// return
return View("Confirm", new ConfirmModel("Delete admin",
$"Unable to proceed: As the user <strong>{Html.Encode(user.Email)}</strong> is the last enabled admin, it cannot be removed."));
}
@ -281,6 +300,29 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(ListUsers));
}
[HttpGet("server/users/{userId}/approve")]
public async Task<IActionResult> ApproveUser(string userId, bool approved)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
return View("Confirm", new ConfirmModel($"{(approved ? "Approve" : "Unapprove")} user", $"The user <strong>{Html.Encode(user.Email)}</strong> will be {(approved ? "approved" : "unapproved")}. Are you sure?", (approved ? "Approve" : "Unapprove")));
}
[HttpPost("server/users/{userId}/approve")]
public async Task<IActionResult> ApproveUserPost(string userId, bool approved)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
await _userService.SetUserApproval(userId, approved, Request.GetAbsoluteRootUri());
TempData[WellKnownTempData.SuccessMessage] = $"User {(approved ? "approved" : "unapproved")}";
return RedirectToAction(nameof(ListUsers));
}
[HttpGet("server/users/{userId}/verification-email")]
public async Task<IActionResult> SendVerificationEmail(string userId)
{
@ -332,5 +374,8 @@ namespace BTCPayServer.Controllers
[Display(Name = "Email confirmed?")]
public bool EmailConfirmed { get; set; }
[Display(Name = "User approved?")]
public bool Approved { get; set; }
}
}

View file

@ -0,0 +1,12 @@
using System;
using BTCPayServer.Data;
namespace BTCPayServer.Events
{
public class UserApprovedEvent
{
public ApplicationUser User { get; set; }
public bool Approved { get; set; }
public Uri RequestUri { get; set; }
}
}

View file

@ -1,11 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
using BTCPayServer.Logging;
using BTCPayServer.NTag424;
using BTCPayServer.Services;
using Microsoft.Extensions.Logging;

View file

@ -23,6 +23,12 @@ namespace BTCPayServer.Services
$"Please confirm your account by clicking this link: <a href='{HtmlEncoder.Default.Encode(link)}'>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>");
}
public static void SendSetPasswordConfirmation(this IEmailSender emailSender, MailboxAddress address, string link, bool newPassword)
{
var subject = $"{(newPassword ? "Set" : "Update")} Password";

View file

@ -29,6 +29,11 @@ namespace Microsoft.AspNetCore.Mvc
new { userId, code }, scheme, host, pathbase);
}
public static string LoginLink(this LinkGenerator urlHelper, string scheme, HostString host, string pathbase)
{
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)
{
return urlHelper.GetUriByAction(

View file

@ -6,6 +6,8 @@ using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -19,20 +21,27 @@ namespace BTCPayServer.HostedServices
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly EmailSenderFactory _emailSenderFactory;
private readonly NotificationSender _notificationSender;
private readonly LinkGenerator _generator;
public UserEventHostedService(EventAggregator eventAggregator, UserManager<ApplicationUser> userManager,
EmailSenderFactory emailSenderFactory, LinkGenerator generator, Logs logs) : base(eventAggregator, logs)
public UserEventHostedService(
EventAggregator eventAggregator,
UserManager<ApplicationUser> userManager,
EmailSenderFactory emailSenderFactory,
NotificationSender notificationSender,
LinkGenerator generator,
Logs logs) : base(eventAggregator, logs)
{
_userManager = userManager;
_emailSenderFactory = emailSenderFactory;
_notificationSender = notificationSender;
_generator = generator;
}
protected override void SubscribeToEvents()
{
Subscribe<UserRegisteredEvent>();
Subscribe<UserApprovedEvent>();
Subscribe<UserPasswordResetRequestedEvent>();
}
@ -40,30 +49,39 @@ namespace BTCPayServer.HostedServices
{
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 {userRegisteredEvent.User.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}");
if (!userRegisteredEvent.User.EmailConfirmed && userRegisteredEvent.User.RequiresEmailConfirmation)
$"A new user just registered {user.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}");
if (user.RequiresApproval && !user.Approved)
{
code = await _userManager.GenerateEmailConfirmationTokenAsync(userRegisteredEvent.User);
callbackUrl = _generator.EmailConfirmationLink(userRegisteredEvent.User.Id, code,
userRegisteredEvent.RequestUri.Scheme,
new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port),
userRegisteredEvent.RequestUri.PathAndQuery);
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 = userRegisteredEvent.User.GetMailboxAddress();
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(address, callbackUrl);
address = user.GetMailboxAddress();
emailSender = await _emailSenderFactory.GetEmailSender();
emailSender.SendEmailConfirmation(address, callbackUrl);
}
else if (!await _userManager.HasPasswordAsync(userRegisteredEvent.User))
{
userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent()
userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent
{
CallbackUrlGenerated = userRegisteredEvent.CallbackUrlGenerated,
User = userRegisteredEvent.User,
User = user,
RequestUri = userRegisteredEvent.RequestUri
};
goto passwordSetter;
@ -72,22 +90,33 @@ namespace BTCPayServer.HostedServices
{
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:
code = await _userManager.GeneratePasswordResetTokenAsync(userPasswordResetRequestedEvent.User);
var newPassword = await _userManager.HasPasswordAsync(userPasswordResetRequestedEvent.User);
callbackUrl = _generator.ResetPasswordCallbackLink(userPasswordResetRequestedEvent.User.Id, code,
userPasswordResetRequestedEvent.RequestUri.Scheme,
new HostString(userPasswordResetRequestedEvent.RequestUri.Host,
userPasswordResetRequestedEvent.RequestUri.Port),
userPasswordResetRequestedEvent.RequestUri.PathAndQuery);
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 = userPasswordResetRequestedEvent.User.GetMailboxAddress();
(await _emailSenderFactory.GetEmailSender())
.SendSetPasswordConfirmation(address, callbackUrl, newPassword);
address = user.GetMailboxAddress();
emailSender = await _emailSenderFactory.GetEmailSender();
emailSender.SendSetPasswordConfirmation(address, callbackUrl, newPassword);
break;
}
}

View file

@ -436,8 +436,8 @@ namespace BTCPayServer.Hosting
services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
services.AddSingleton<INotificationHandler, PluginUpdateNotification.Handler>();
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
services.AddSingleton<INotificationHandler, ExternalPayoutTransactionNotification.Handler>();

View file

@ -11,7 +11,8 @@ namespace BTCPayServer.Models.ServerViewModels
public string Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public bool Verified { get; set; }
public bool? EmailConfirmed { get; set; }
public bool? Approved { get; set; }
public bool Disabled { get; set; }
public bool IsAdmin { get; set; }
public DateTimeOffset? Created { get; set; }

View file

@ -7,6 +7,7 @@ using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
@ -58,14 +59,12 @@ namespace BTCPayServer.Security.Greenfield
return AuthenticateResult.NoResult();
var key = await _apiKeyRepository.GetKey(apiKey, true);
if (key == null || await _userManager.IsLockedOutAsync(key.User))
if (!UserService.TryCanLogin(key?.User, out var error))
{
return AuthenticateResult.Fail("ApiKey authentication failed");
return AuthenticateResult.Fail($"ApiKey authentication failed: {error}");
}
List<Claim> claims = new List<Claim>();
claims.Add(new Claim(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId));
var claims = new List<Claim> { new (_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId) };
claims.AddRange((await _userManager.GetRolesAsync(key.User)).Select(s => new Claim(_identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, s)));
claims.AddRange(Permission.ToPermissions(key.GetBlob()?.Permissions ?? Array.Empty<string>()).Select(permission =>
new Claim(GreenfieldConstants.ClaimTypes.Permission, permission.ToString())));

View file

@ -7,6 +7,7 @@ using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@ -66,6 +67,10 @@ namespace BTCPayServer.Security.Greenfield
.FirstOrDefaultAsync(applicationUser =>
applicationUser.NormalizedUserName == _userManager.NormalizeName(username));
if (!UserService.TryCanLogin(user, out var error))
{
return AuthenticateResult.Fail($"Basic authentication failed: {error}");
}
if (user.Fido2Credentials.Any())
{
return AuthenticateResult.Fail("Cannot use Basic authentication with multi-factor is enabled.");

View file

@ -0,0 +1,57 @@
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Configuration;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Services.Notifications.Blobs;
internal class NewUserRequiresApprovalNotification : BaseNotification
{
private const string TYPE = "newuserrequiresapproval";
public string UserId { get; set; }
public string UserEmail { get; set; }
public override string Identifier => TYPE;
public override string NotificationType => TYPE;
public NewUserRequiresApprovalNotification()
{
}
public NewUserRequiresApprovalNotification(ApplicationUser user)
{
UserId = user.Id;
UserEmail = user.Email;
}
internal class Handler : NotificationHandler<NewUserRequiresApprovalNotification>
{
private readonly LinkGenerator _linkGenerator;
private readonly BTCPayServerOptions _options;
public Handler(LinkGenerator linkGenerator, BTCPayServerOptions options)
{
_linkGenerator = linkGenerator;
_options = options;
}
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return [(TYPE, "New user requires approval")];
}
}
protected override void FillViewModel(NewUserRequiresApprovalNotification notification, NotificationViewModel vm)
{
vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType;
vm.Body = $"New user {notification.UserEmail} requires approval.";
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIServerController.User),
"UIServer",
new { userId = notification.UserId }, _options.RootPath);
}
}
}

View file

@ -1,6 +1,6 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Services.Apps;
using BTCPayServer.Validation;
using Newtonsoft.Json;
@ -14,6 +14,19 @@ namespace BTCPayServer.Services
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Disable new user registration on the server")]
public bool LockSubscription { get; set; }
[JsonIgnore]
[Display(Name = "Enable new user registration on the server")]
public bool EnableRegistration
{
get => !LockSubscription;
set { LockSubscription = !value; }
}
[DefaultValue(true)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Require new users to be approved by an admin after registration")]
public bool RequiresUserApproval { get; set; } = true;
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Discourage search engines from indexing this site")]
@ -40,6 +53,14 @@ namespace BTCPayServer.Services
[Display(Name = "Disable non-admins access to the user creation API endpoint")]
public bool DisableNonAdminCreateUserApi { get; set; }
[JsonIgnore]
[Display(Name = "Non-admins can access the user creation API endpoint")]
public bool EnableNonAdminCreateUserApi
{
get => !DisableNonAdminCreateUserApi;
set { DisableNonAdminCreateUserApi = !value; }
}
public const string DefaultPluginSource = "https://plugin-builder.btcpayserver.org";
[UriAttribute]

View file

@ -1,10 +1,13 @@
#nullable enable
using System;
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;
@ -20,6 +23,7 @@ namespace BTCPayServer.Services
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;
@ -27,6 +31,7 @@ namespace BTCPayServer.Services
IServiceProvider serviceProvider,
StoredFileRepository storedFileRepository,
FileService fileService,
EventAggregator eventAggregator,
StoreRepository storeRepository,
ApplicationDbContextFactory applicationDbContextFactory,
ILogger<UserService> logger)
@ -34,6 +39,7 @@ namespace BTCPayServer.Services
_serviceProvider = serviceProvider;
_storedFileRepository = storedFileRepository;
_fileService = fileService;
_eventAggregator = eventAggregator;
_storeRepository = storeRepository;
_applicationDbContextFactory = applicationDbContextFactory;
_logger = logger;
@ -46,26 +52,89 @@ namespace BTCPayServer.Services
(userRole, role) => role.Name).ToArray()))).ToListAsync();
}
public static ApplicationUserData FromModel(ApplicationUser data, string?[] roles)
{
return new ApplicationUserData()
return new ApplicationUserData
{
Id = data.Id,
Email = data.Email,
EmailConfirmed = data.EmailConfirmed,
RequiresEmailConfirmation = data.RequiresEmailConfirmation,
Approved = data.Approved,
RequiresApproval = data.RequiresApproval,
Created = data.Created,
Roles = roles,
Disabled = data.LockoutEnabled && data.LockoutEnd is not null && DateTimeOffset.UtcNow < data.LockoutEnd.Value.UtcDateTime
};
}
private bool IsDisabled(ApplicationUser user)
private static bool IsEmailConfirmed(ApplicationUser user)
{
return user.EmailConfirmed || !user.RequiresEmailConfirmation;
}
private static bool IsApproved(ApplicationUser user)
{
return user.Approved || !user.RequiresApproval;
}
private static bool IsDisabled(ApplicationUser user)
{
return user.LockoutEnabled && user.LockoutEnd is not null &&
DateTimeOffset.UtcNow < user.LockoutEnd.Value.UtcDateTime;
}
public static bool TryCanLogin([NotNullWhen(true)] ApplicationUser? user, [MaybeNullWhen(true)] out string error)
{
error = null;
if (user == null)
{
error = "Invalid login attempt.";
return false;
}
if (!IsEmailConfirmed(user))
{
error = "You must have a confirmed email to log in.";
return false;
}
if (!IsApproved(user))
{
error = "Your user account requires approval by an admin before you can log in.";
return false;
}
if (IsDisabled(user))
{
error = "Your user account is currently disabled.";
return false;
}
return true;
}
public async Task<bool> SetUserApproval(string userId, bool approved, Uri requestUri)
{
using var scope = _serviceProvider.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = await userManager.FindByIdAsync(userId);
if (user is null || !user.RequiresApproval || user.Approved == approved)
{
return false;
}
user.Approved = approved;
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 });
}
else
{
_logger.LogError("Failed to {Action} user {UserId}", approved ? "approve" : "unapprove", user.Id);
}
return succeeded;
}
public async Task<bool?> ToggleUser(string userId, DateTimeOffset? lockedOutDeadline)
{
using var scope = _serviceProvider.CreateScope();
@ -163,7 +232,6 @@ namespace BTCPayServer.Services
}
}
public async Task<bool> IsUserTheOnlyOneAdmin(ApplicationUser user)
{
using var scope = _serviceProvider.CreateScope();
@ -175,7 +243,7 @@ namespace BTCPayServer.Services
}
var adminUsers = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var enabledAdminUsers = adminUsers
.Where(applicationUser => !IsDisabled(applicationUser))
.Where(applicationUser => !IsDisabled(applicationUser) && IsApproved(applicationUser))
.Select(applicationUser => applicationUser.Id).ToList();
return enabledAdminUsers.Count == 1 && enabledAdminUsers.Contains(user.Id);

View file

@ -10,7 +10,7 @@
</p>
<form asp-action="ForgotPassword" method="post">
<div asp-validation-summary="All"></div>
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
<div class="form-group">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" />

View file

@ -1,5 +1,4 @@
@using BTCPayServer.Abstractions.Extensions
@model DateTimeOffset?
@model DateTimeOffset?
@{
ViewData["Title"] = "Account disabled";
Layout = "_LayoutSignedOut";

View file

@ -9,16 +9,16 @@
<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="login-form" asp-action="Login">
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)">
<div asp-validation-summary="ModelOnly"></div>
<div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
<div class="form-group">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" required autofocus/>
<input asp-for="Email" class="form-control" required autofocus />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<div class="d-flex justify-content-between">
<label asp-for="Password" class="form-label"></label>
<a asp-action="ForgotPassword" >Forgot password?</a>
<a asp-action="ForgotPassword" tabindex="-1">Forgot password?</a>
</div>
<div class="input-group d-flex">
<input asp-for="Password" class="form-control" required />

View file

@ -8,7 +8,7 @@
<form asp-route-returnUrl="@ViewData["ReturnUrl"]" asp-route-logon="true" method="post">
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)" >
<div asp-validation-summary="ModelOnly"></div>
<div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
<div class="form-group">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" required autofocus />

View file

@ -13,9 +13,9 @@
<partial name="_ValidationScriptsPartial" />
}
@if (Model.LoginWith2FaViewModel != null && Model.LoginWithFido2ViewModel != null&& Model.LoginWithLNURLAuthViewModel != null)
@if (Model.LoginWith2FaViewModel != null && Model.LoginWithFido2ViewModel != null && Model.LoginWithLNURLAuthViewModel != null)
{
<div asp-validation-summary="ModelOnly"></div>
<div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
}
else if (Model.LoginWith2FaViewModel == null && Model.LoginWithFido2ViewModel == null && Model.LoginWithLNURLAuthViewModel == null)
{

View file

@ -5,7 +5,7 @@
}
<form method="post" asp-action="SetPassword">
<div asp-validation-summary="All"></div>
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
<input asp-for="Code" type="hidden"/>
<input asp-for="EmailSetInternally" type="hidden"/>
@if (Model.EmailSetInternally)

View file

@ -30,11 +30,6 @@
<partial name="LayoutHead"/>
<link href="~/vendor/bootstrap-vue/bootstrap-vue.min.css" asp-append-version="true" rel="stylesheet"/>
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
<style>
.no-marker > ul {
list-style-type: none;
}
</style>
</head>
<body class="min-vh-100">
<div id="app" class="d-flex flex-column min-vh-100 pb-l">

View file

@ -36,8 +36,6 @@
<span asp-validation-for="IsAdmin" class="text-danger"></span>
</div>
}
@if (ViewData["AllowRequestEmailConfirmation"] is true)
{
<div class="form-group form-check">
@ -46,8 +44,16 @@
<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>
<button id="Save" type="submit" class="btn btn-primary mt-2" name="command" value="Save">Create account</button>
</form>
</div>
</div>

View file

@ -3,16 +3,12 @@
@{
ViewData.SetActivePage(ServerNavPages.Users);
var nextUserEmailSortOrder = (string)ViewData["NextUserEmailSortOrder"];
String userEmailSortOrder = null;
switch (nextUserEmailSortOrder)
var userEmailSortOrder = nextUserEmailSortOrder switch
{
case "asc":
userEmailSortOrder = "desc";
break;
case "desc":
userEmailSortOrder = "asc";
break;
}
"asc" => "desc",
"desc" => "asc",
_ => null
};
var sortIconClass = "fa-sort";
if (userEmailSortOrder != null)
@ -20,8 +16,8 @@
sortIconClass = $"fa-sort-alpha-{userEmailSortOrder}";
}
var sortByDesc = "Sort by descending...";
var sortByAsc = "Sort by ascending...";
const string sortByDesc = "Sort by descending...";
const string sortByAsc = "Sort by ascending...";
}
<div class="d-flex align-items-center justify-content-between mb-3">
@ -31,14 +27,8 @@
</a>
</div>
<form asp-action="ListUsers" asp-route-sortOrder="@(userEmailSortOrder)" style="max-width:640px">
<div class="input-group">
<input asp-for="SearchTerm" class="form-control" placeholder="Search by email..." />
<button type="submit" class="btn btn-secondary" title="Search by email">
<span class="fa fa-search"></span> Search
</button>
</div>
<span asp-validation-for="SearchTerm" class="text-danger"></span>
<form asp-action="ListUsers" asp-route-sortOrder="@(userEmailSortOrder)" style="max-width:640px" method="get">
<input asp-for="SearchTerm" class="form-control" placeholder="Search by email..." />
</form>
<div class="table-responsive">
@ -53,58 +43,52 @@
title="@(nextUserEmailSortOrder == "desc" ? sortByAsc : sortByDesc)"
>
Email
<span class="fa @(sortIconClass)" />
<span class="fa @(sortIconClass)"></span>
</a>
</th>
<th >Created</th>
<th class="text-center">Verified</th>
<th class="text-center">Enabled</th>
<th class="text-end">Actions</th>
<th>Created</th>
<th>Status</th>
<th class="actions-col"></th>
</tr>
</thead>
<tbody>
<tbody id="UsersList">
@foreach (var user in Model.Users)
{
var status = user switch
{
{ Disabled: true } => ("Disabled", "danger"),
{ Approved: false } => ("Pending Approval", "warning"),
{ EmailConfirmed: false } => ("Pending Email Verification", "warning"),
_ => ("Active", "success")
};
<tr>
<td class="d-flex align-items-center">
<span class="me-2">@user.Email</span>
<td class="d-flex align-items-center gap-2">
<span class="user-email">@user.Email</span>
@foreach (var role in user.Roles)
{
<span class="badge bg-info">@Model.Roles[role]</span>
}
</td>
<td>@user.Created?.ToBrowserDate()</td>
<td class="text-center">
@if (user.Verified)
{
<span class="text-success fa fa-check"></span>
}
else
{
<span class="text-danger fa fa-times"></span>
}
<td>
<span class="user-status badge bg-@status.Item2">@status.Item1</span>
</td>
<td class="text-center">
@if (!user.Disabled)
{
<span class="text-success fa fa-check" title="User is enabled"></span>
}
else
{
<span class="text-danger fa fa-times" title="User is disabled"></span>
}
</td>
<td class="text-end">
@if (!user.Verified && !user.Disabled) {
<a asp-action="SendVerificationEmail" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="This will send a verification email to <strong>@Html.Encode(user.Email)</strong>.">Resend verification email</a>
<span>-</span>
}
<a asp-action="User" asp-route-userId="@user.Id">Edit</a> <span> - </span> <a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a>
- <a asp-action="ToggleUser"
asp-route-enable="@user.Disabled"
asp-route-userId="@user.Id">
@(user.Disabled ? "Enable" : "Disable")
</a>
<td class="actions-col">
<div class="d-inline-flex align-items-center gap-3">
@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 })
{
<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
{
<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>
</div>
</td>
</tr>
}

View file

@ -11,15 +11,10 @@
@section PageHeadContent {
<style>
#AllowLightningInternalNodeForAll ~ .info-note,
#AllowHotWalletRPCImportForAll ~ .info-note,
#AllowHotWalletForAll ~ .info-note,
#DisableNonAdminCreateUserApi:checked ~ .info-note,
#LockSubscription:checked ~ .info-note { display: none; }
#AllowLightningInternalNodeForAll:checked ~ .info-note,
#AllowHotWalletRPCImportForAll:checked ~ .info-note,
#AllowHotWalletForAll:checked ~ .info-note { display: inline-flex; }
input[type="checkbox"] ~ .info-note,
input[type="checkbox"] ~ .subsettings { display: none; }
input[type="checkbox"]:checked ~ .info-note { display: flex; max-width: 44em; }
input[type="checkbox"]:checked ~ .subsettings { display: block; }
</style>
}
@ -31,10 +26,59 @@
}
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="col-xl-10 col-xxl-constrain">
<form method="post" class="d-flex flex-column">
<div class="form-group mb-5">
<h4 class="mb-3">Existing User Settings</h4>
<h4 class="mb-3">Registration Settings</h4>
<div class="form-check my-3">
<input asp-for="EnableRegistration" type="checkbox" class="form-check-input"/>
<label asp-for="EnableRegistration" class="form-check-label"></label>
<span asp-validation-for="EnableRegistration" class="text-danger"></span>
<div class="info-note mt-2 text-warning" role="alert">
<vc:icon symbol="warning"/>
Caution: Enabling public user registration means anyone can register to your server and may expose your BTCPay Server instance to potential security risks from unknown users.
</div>
<div class="subsettings">
<div class="form-check my-3">
@{
var emailSettings = (await _SettingsRepository.GetSettingAsync<EmailSettings>()) ?? new EmailSettings();
/* The "|| Model.RequiresConfirmedEmail" check is for the case when a user had checked
the checkbox without first configuring the e-mail settings so that they can uncheck it. */
var isEmailConfigured = emailSettings.IsComplete() || Model.RequiresConfirmedEmail;
}
<input asp-for="RequiresConfirmedEmail" type="checkbox" class="form-check-input" disabled="@(isEmailConfigured ? null : "disabled")"/>
<label asp-for="RequiresConfirmedEmail" class="form-check-label"></label>
<a href="https://docs.btcpayserver.org/FAQ/ServerSettings/#how-to-allow-registration-on-my-btcpay-server" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<span asp-validation-for="RequiresConfirmedEmail" class="text-danger"></span>
@if (!isEmailConfigured)
{
<div class="mb-2">
<span class="text-secondary">Your email server has not been configured. <a asp-controller="UIServer" asp-action="Emails">Please configure it first.</a></span>
</div>
}
</div>
<div class="form-check my-3">
<input asp-for="RequiresUserApproval" type="checkbox" class="form-check-input"/>
<label asp-for="RequiresUserApproval" class="form-check-label"></label>
<span asp-validation-for="RequiresUserApproval" class="text-danger"></span>
</div>
<div class="form-check my-3">
<input asp-for="EnableNonAdminCreateUserApi" type="checkbox" class="form-check-input"/>
<label asp-for="EnableNonAdminCreateUserApi" class="form-check-label"></label>
<span asp-validation-for="EnableNonAdminCreateUserApi" class="text-danger"></span>
<div class="info-note mt-2 text-warning" role="alert">
<vc:icon symbol="warning"/>
Caution: Allowing non-admins to have access to API endpoints may expose your BTCPay Server instance to potential security risks from unknown users.
</div>
</div>
</div>
</div>
</div>
<div class="form-group mb-5">
<h4 class="mb-3">User Settings</h4>
<div class="form-check my-3">
<input asp-for="AllowLightningInternalNodeForAll" type="checkbox" class="form-check-input"/>
<label asp-for="AllowLightningInternalNodeForAll" class="form-check-label"></label>
@ -70,49 +114,6 @@
</div>
</div>
<div class="form-group mb-5">
<h4 class="mb-3">New User Settings</h4>
<div class="form-check my-3">
@{
var emailSettings = (await _SettingsRepository.GetSettingAsync<EmailSettings>()) ?? new EmailSettings();
/* The "|| Model.RequiresConfirmedEmail" check is for the case when a user had checked
the checkbox without first configuring the e-mail settings so that they can uncheck it. */
var isEmailConfigured = emailSettings.IsComplete() || Model.RequiresConfirmedEmail;
}
<input asp-for="RequiresConfirmedEmail" type="checkbox" class="form-check-input" disabled="@(isEmailConfigured ? null : "disabled")"/>
<label asp-for="RequiresConfirmedEmail" class="form-check-label"></label>
<a href="https://docs.btcpayserver.org/FAQ/ServerSettings/#how-to-allow-registration-on-my-btcpay-server" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<span asp-validation-for="RequiresConfirmedEmail" class="text-danger"></span>
@if (!isEmailConfigured)
{
<div class="mb-2">
<span class="text-secondary">Your email server has not been configured. <a asp-controller="UIServer" asp-action="Emails">Please configure it first.</a></span>
</div>
}
</div>
<div class="form-check my-3">
<input asp-for="LockSubscription" type="checkbox" class="form-check-input"/>
<label asp-for="LockSubscription" class="form-check-label"></label>
<span asp-validation-for="LockSubscription" class="text-danger"></span>
<div class="info-note mt-2 text-warning" role="alert">
<vc:icon symbol="warning"/>
Caution: Enabling public user registration means anyone can register to your server and may expose your BTCPay Server instance to potential security risks from unknown users.
</div>
</div>
<div class="form-check my-3">
<input asp-for="DisableNonAdminCreateUserApi" type="checkbox" class="form-check-input"/>
<label asp-for="DisableNonAdminCreateUserApi" class="form-check-label"></label>
<span asp-validation-for="DisableNonAdminCreateUserApi" class="text-danger"></span>
<div class="info-note mt-2 text-warning" role="alert">
<vc:icon symbol="warning"/>
Caution: Allowing non-admins to have access to API endpoints may expose your BTCPay Server instance to potential security risks from unknown users.
</div>
</div>
</div>
<div class="form-group mb-5">
<h4 class="mb-3">Email Settings</h4>
<div class="form-check my-3">
@ -181,7 +182,7 @@
<h4 class="mt-5">Customization Settings</h4>
<div class="form-group mb-5">
<label asp-for="RootAppId" class="form-label"></label>
<select asp-for="RootAppId" asp-items="@(new SelectList(ViewBag.AppsList, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.RootAppId))" class="form-select w-auto"></select>
<select asp-for="RootAppId" asp-items="@(new SelectList(ViewBag.AppsList, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.RootAppId))" class="form-select"></select>
@if (!Model.DomainToAppMapping.Any())
{
<button id="AddDomainButton" type="submit" name="command" value="add-domain" class="btn btn-link px-0">Map specific domains to specific apps</button>
@ -214,7 +215,7 @@
<label asp-for="DomainToAppMapping[index].AppId" class="form-label"></label>
<select asp-for="DomainToAppMapping[index].AppId"
asp-items="@(new SelectList(ViewBag.AppsList, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.DomainToAppMapping[index].AppId))"
class="form-select w-auto">
class="form-select">
</select>
<span asp-validation-for="DomainToAppMapping[index].AppId" class="text-danger"></span>
</div>

View file

@ -5,14 +5,26 @@
<h3 class="mb-4">@ViewData["Title"]</h3>
<div class="row">
<div class="col-md-8">
<form method="post">
<div class="form-group form-check mb-4">
<input asp-for="IsAdmin" type="checkbox" class="form-check-input" />
<label asp-for="IsAdmin" class="form-check-label">Is admin</label>
</div>
<button name="command" type="submit" class="btn btn-primary" value="Save">Save</button>
</form>
<form method="post">
<div class="form-check my-3">
<input asp-for="IsAdmin" type="checkbox" class="form-check-input" />
<label asp-for="IsAdmin" class="form-check-label">User is admin</label>
</div>
</div>
@if (Model.Approved.HasValue)
{
<div class="form-check my-3">
<input id="Approved" name="Approved" type="checkbox" value="true" class="form-check-input" @(Model.Approved.Value ? "checked" : "") />
<label for="Approved" class="form-check-label">User is approved</label>
</div>
<input name="Approved" type="hidden" value="false">
}
@if (Model.EmailConfirmed.HasValue)
{
<div class="form-check my-3">
<input id="EmailConfirmed" name="EmailConfirmed" value="true" type="checkbox" class="form-check-input" @(Model.EmailConfirmed.Value ? "checked" : "") />
<label for="EmailConfirmed" class="form-check-label">Email address is confirmed</label>
</div>
<input name="EmailConfirmed" type="hidden" value="false">
}
<button name="command" type="submit" class="btn btn-primary mt-3" value="Save" id="SaveUser">Save</button>
</form>

View file

@ -42,6 +42,11 @@
margin-bottom: 0;
}
.no-marker > ul {
list-style-type: none;
padding-left: 0;
}
/* General and site-wide Bootstrap modifications */
p {
margin-bottom: 1.5rem;

View file

@ -255,7 +255,7 @@
"tags": [
"Users"
],
"summary": "Toggle user",
"summary": "Toggle user lock out",
"description": "Lock or unlock a user.\n\nMust be an admin to perform this operation.\n\nAttempting to lock the only admin user will not succeed.",
"parameters": [
{
@ -283,10 +283,63 @@
"description": "User has been successfully toggled"
},
"401": {
"description": "Missing authorization for deleting the user"
"description": "Missing authorization for locking the user"
},
"403": {
"description": "Authorized but forbidden to disable the user. Can happen if you attempt to disable the only admin user."
"description": "Authorized but forbidden to lock the user. Can happen if you attempt to disable the only admin user."
},
"404": {
"description": "User with provided ID was not found"
}
},
"security": [
{
"API_Key": [
"btcpay.user.canmodifyserversettings"
],
"Basic": []
}
]
}
},
"/api/v1/users/{idOrEmail}/approve": {
"post": {
"operationId": "Users_ToggleUserApproval",
"tags": [
"Users"
],
"summary": "Toggle user approval",
"description": "Approve or unapprove a user.\n\nMust be an admin to perform this operation.\n\nAttempting to (un)approve a user for which this requirement does not exist will not succeed.",
"parameters": [
{
"name": "idOrEmail",
"in": "path",
"required": true,
"description": "The ID of the user to be un/approved",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApproveUserRequest"
}
}
}
},
"responses": {
"200": {
"description": "User has been successfully toggled"
},
"401": {
"description": "Missing authorization for approving the user"
},
"403": {
"description": "Authorized but forbidden to approve the user. Can happen if you attempt to set the status of a user that does not have the approval requirement."
},
"404": {
"description": "User with provided ID was not found"
@ -325,7 +378,15 @@
},
"requiresEmailConfirmation": {
"type": "boolean",
"description": "True if the email requires email confirmation to log in"
"description": "True if the email requires confirmation to log in"
},
"approved": {
"type": "boolean",
"description": "True if an admin has approved the user"
},
"requiresApproval": {
"type": "boolean",
"description": "True if the instance requires approval to log in"
},
"created": {
"nullable": true,
@ -355,6 +416,16 @@
"description": "Whether to lock or unlock the user"
}
}
},
"ApproveUserRequest": {
"type": "object",
"additionalProperties": false,
"properties": {
"approved": {
"type": "boolean",
"description": "Whether to approve or unapprove the user"
}
}
}
}
},