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; 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) public virtual async Task<ApplicationUserData[]> GetUsers(CancellationToken token = default)
{ {
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token); var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token);

View file

@ -25,6 +25,16 @@ namespace BTCPayServer.Client.Models
/// </summary> /// </summary>
public bool RequiresEmailConfirmation { get; set; } 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> /// <summary>
/// the roles of the user /// the roles of the user
/// </summary> /// </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 class ApplicationUser : IdentityUser, IHasBlob<UserBlob>
{ {
public bool RequiresEmailConfirmation { get; set; } public bool RequiresEmailConfirmation { get; set; }
public bool RequiresApproval { get; set; }
public bool Approved { get; set; }
public List<StoredFile> StoredFiles { get; set; } public List<StoredFile> StoredFiles { get; set; }
[Obsolete("U2F support has been replace with FIDO2")] [Obsolete("U2F support has been replace with FIDO2")]
public List<U2FDevice> U2FDevices { get; set; } 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") b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool>("Approved")
.HasColumnType("INTEGER");
b.Property<byte[]>("Blob") b.Property<byte[]>("Blob")
.HasColumnType("BLOB"); .HasColumnType("BLOB");
@ -158,6 +161,9 @@ namespace BTCPayServer.Migrations
b.Property<bool>("PhoneNumberConfirmed") b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool>("RequiresApproval")
.HasColumnType("INTEGER");
b.Property<bool>("RequiresEmailConfirmation") b.Property<bool>("RequiresEmailConfirmation")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

View file

@ -245,6 +245,9 @@ namespace BTCPayServer.Tests
rateProvider.Providers.Add("kraken", kraken); 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..."); TestLogs.LogInformation("Waiting site is operational...");
await WaitSiteIsOperational(); await WaitSiteIsOperational();

View file

@ -694,14 +694,10 @@ namespace BTCPayServer.Tests
// Try loading 1 user by email. Loading myself. // Try loading 1 user by email. Loading myself.
await AssertHttpError(403, async () => await badClient.GetUserByIdOrEmail(badUser.Email)); 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? // Why is this line needed? I saw it in "CanDeleteUsersViaApi" as well. Is this part of the cleanup?
tester.Stores.Remove(adminUser.StoreId); tester.Stores.Remove(adminUser.StoreId);
} }
[Fact(Timeout = TestTimeout)] [Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
public async Task CanCreateUsersViaAPI() public async Task CanCreateUsersViaAPI()
@ -3571,6 +3567,78 @@ namespace BTCPayServer.Tests
await newUserBasicClient.GetCurrentUser(); 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)] [Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
[Trait("Lightning", "Lightning")] [Trait("Lightning", "Lightning")]

View file

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

View file

@ -405,6 +405,148 @@ namespace BTCPayServer.Tests
Assert.Contains("/login", s.Driver.Url); 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)] [Fact(Timeout = TestTimeout)]
public async Task CanUseSSHService() public async Task CanUseSSHService()
{ {

View file

@ -2325,17 +2325,21 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester(newDb: true); using var tester = CreateServerTester(newDb: true);
await tester.StartAsync(); await tester.StartAsync();
var f = tester.PayTester.GetService<ApplicationDbContextFactory>(); var f = tester.PayTester.GetService<ApplicationDbContextFactory>();
const string id = "BTCPayServer.Services.PoliciesSettings";
using (var ctx = f.CreateContext()) using (var ctx = f.CreateContext())
{ {
var setting = new SettingData() { Id = "BTCPayServer.Services.PoliciesSettings" }; // remove existing policies setting
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(); 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); ctx.Settings.Add(setting);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
} }
await RestartMigration(tester); await RestartMigration(tester);
using (var ctx = f.CreateContext()) 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); var o = JObject.Parse(setting.Value);
Assert.Equal("Crowdfund", o["RootAppType"].Value<string>()); Assert.Equal("Crowdfund", o["RootAppType"].Value<string>());
o = (JObject)((JArray)o["DomainToAppMapping"])[0]; o = (JObject)((JArray)o["DomainToAppMapping"])[0];

View file

@ -92,6 +92,26 @@ namespace BTCPayServer.Controllers.Greenfield
$"{(request.Locked ? "Locking" : "Unlocking")} user failed"); $"{(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)] [Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/users/")] [HttpGet("~/api/v1/users/")]
public async Task<ActionResult<ApplicationUserData[]>> GetUsers() public async Task<ActionResult<ApplicationUserData[]>> GetUsers()
@ -163,7 +183,9 @@ namespace BTCPayServer.Controllers.Greenfield
UserName = request.Email, UserName = request.Email,
Email = request.Email, Email = request.Email,
RequiresEmailConfirmation = policies.RequiresConfirmedEmail, RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
RequiresApproval = policies.RequiresUserApproval,
Created = DateTimeOffset.UtcNow, Created = DateTimeOffset.UtcNow,
Approved = !anyAdmin && isAdmin // auto-approve first admin
}; };
var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password); var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password);
if (!passwordValidation.Succeeded) if (!passwordValidation.Succeeded)

View file

@ -1084,6 +1084,13 @@ namespace BTCPayServer.Controllers.Greenfield
new LockUserRequest { Locked = disabled })); 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, public override async Task<OnChainWalletTransactionData> PatchOnChainWalletTransaction(string storeId,
string cryptoCode, string transactionId, string cryptoCode, string transactionId,
PatchOnChainTransactionRequest request, bool force = false, CancellationToken token = default) PatchOnChainTransactionRequest request, bool force = false, CancellationToken token = default)

View file

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

View file

@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -8,25 +7,21 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.Models;
using BTCPayServer.Models.ServerViewModels; using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services; using BTCPayServer.Services;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MimeKit;
namespace BTCPayServer.Controllers namespace BTCPayServer.Controllers
{ {
public partial class UIServerController public partial class UIServerController
{ {
[Route("server/users")] [HttpGet("server/users")]
public async Task<IActionResult> ListUsers( public async Task<IActionResult> ListUsers(
[FromServices] RoleManager<IdentityRole> roleManager, [FromServices] RoleManager<IdentityRole> roleManager,
UsersViewModel model, UsersViewModel model,
string sortOrder = null string sortOrder = null)
)
{ {
model = this.ParseListQuery(model ?? new UsersViewModel()); model = this.ParseListQuery(model ?? new UsersViewModel());
@ -64,7 +59,8 @@ namespace BTCPayServer.Controllers
Name = u.UserName, Name = u.UserName,
Email = u.Email, Email = u.Email,
Id = u.Id, Id = u.Id,
Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation, EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
Approved = u.RequiresApproval ? u.Approved : null,
Created = u.Created, Created = u.Created,
Roles = u.UserRoles.Select(role => role.RoleId), Roles = u.UserRoles.Select(role => role.RoleId),
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime
@ -74,44 +70,67 @@ namespace BTCPayServer.Controllers
return View(model); return View(model);
} }
[Route("server/users/{userId}")] [HttpGet("server/users/{userId}")]
public new async Task<IActionResult> User(string userId) public new async Task<IActionResult> User(string userId)
{ {
var user = await _UserManager.FindByIdAsync(userId); var user = await _UserManager.FindByIdAsync(userId);
if (user == null) if (user == null)
return NotFound(); return NotFound();
var roles = await _UserManager.GetRolesAsync(user); var roles = await _UserManager.GetRolesAsync(user);
var userVM = new UsersViewModel.UserViewModel var model = new UsersViewModel.UserViewModel
{ {
Id = user.Id, Id = user.Id,
Email = user.Email, Email = user.Email,
Verified = user.EmailConfirmed || !user.RequiresEmailConfirmation, EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
Approved = user.RequiresApproval ? user.Approved : null,
IsAdmin = Roles.HasServerAdmin(roles) IsAdmin = Roles.HasServerAdmin(roles)
}; };
return View(userVM); return View(model);
} }
[Route("server/users/{userId}")] [HttpPost("server/users/{userId}")]
[HttpPost]
public new async Task<IActionResult> User(string userId, UsersViewModel.UserViewModel viewModel) public new async Task<IActionResult> User(string userId, UsersViewModel.UserViewModel viewModel)
{ {
var user = await _UserManager.FindByIdAsync(userId); var user = await _UserManager.FindByIdAsync(userId);
if (user == null) if (user == null)
return NotFound(); 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 admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var roles = await _UserManager.GetRolesAsync(user); var roles = await _UserManager.GetRolesAsync(user);
var wasAdmin = Roles.HasServerAdmin(roles); var wasAdmin = Roles.HasServerAdmin(roles);
if (!viewModel.IsAdmin && admins.Count == 1 && wasAdmin) 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."; 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) if (viewModel.IsAdmin != wasAdmin)
{ {
var success = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin); adminStatusChanged = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin);
if (success) }
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"; 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("server/users/new")]
[HttpGet]
public IActionResult CreateUser() public IActionResult CreateUser()
{ {
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail; ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
return View(); return View();
} }
[Route("server/users/new")] [HttpPost("server/users/new")]
[HttpPost]
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model) public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
{ {
var requiresConfirmedEmail = _policiesSettings.RequiresConfirmedEmail; ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
ViewData["AllowRequestEmailConfirmation"] = requiresConfirmedEmail; ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
if (!_Options.CheatMode) if (!_Options.CheatMode)
model.IsAdmin = false; model.IsAdmin = false;
if (ModelState.IsValid) if (ModelState.IsValid)
@ -148,7 +166,9 @@ namespace BTCPayServer.Controllers
UserName = model.Email, UserName = model.Email,
Email = model.Email, Email = model.Email,
EmailConfirmed = model.EmailConfirmed, EmailConfirmed = model.EmailConfirmed,
RequiresEmailConfirmation = requiresConfirmedEmail, RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
RequiresApproval = _policiesSettings.RequiresUserApproval,
Approved = model.Approved,
Created = DateTimeOffset.UtcNow Created = DateTimeOffset.UtcNow
}; };
@ -223,7 +243,6 @@ namespace BTCPayServer.Controllers
{ {
if (await _userService.IsUserTheOnlyOneAdmin(user)) if (await _userService.IsUserTheOnlyOneAdmin(user))
{ {
// return
return View("Confirm", new ConfirmModel("Delete admin", 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.")); $"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)); 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")] [HttpGet("server/users/{userId}/verification-email")]
public async Task<IActionResult> SendVerificationEmail(string userId) public async Task<IActionResult> SendVerificationEmail(string userId)
{ {
@ -332,5 +374,8 @@ namespace BTCPayServer.Controllers
[Display(Name = "Email confirmed?")] [Display(Name = "Email confirmed?")]
public bool EmailConfirmed { get; set; } 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 System.Threading.Tasks;
using BTCPayServer.Configuration;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.NTag424;
using BTCPayServer.Services; using BTCPayServer.Services;
using Microsoft.Extensions.Logging; 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>"); $"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) public static void SendSetPasswordConfirmation(this IEmailSender emailSender, MailboxAddress address, string link, bool newPassword)
{ {
var subject = $"{(newPassword ? "Set" : "Update")} Password"; var subject = $"{(newPassword ? "Set" : "Update")} Password";

View file

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

View file

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

View file

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

View file

@ -11,7 +11,8 @@ namespace BTCPayServer.Models.ServerViewModels
public string Id { get; set; } public string Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string Email { 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 Disabled { get; set; }
public bool IsAdmin { get; set; } public bool IsAdmin { get; set; }
public DateTimeOffset? Created { get; set; } public DateTimeOffset? Created { get; set; }

View file

@ -7,6 +7,7 @@ using System.Text.Encodings.Web;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -58,14 +59,12 @@ namespace BTCPayServer.Security.Greenfield
return AuthenticateResult.NoResult(); return AuthenticateResult.NoResult();
var key = await _apiKeyRepository.GetKey(apiKey, true); var key = await _apiKeyRepository.GetKey(apiKey, true);
if (!UserService.TryCanLogin(key?.User, out var error))
if (key == null || await _userManager.IsLockedOutAsync(key.User))
{ {
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((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 => claims.AddRange(Permission.ToPermissions(key.GetBlob()?.Permissions ?? Array.Empty<string>()).Select(permission =>
new Claim(GreenfieldConstants.ClaimTypes.Permission, permission.ToString()))); new Claim(GreenfieldConstants.ClaimTypes.Permission, permission.ToString())));

View file

@ -7,6 +7,7 @@ using System.Text.Encodings.Web;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -66,6 +67,10 @@ namespace BTCPayServer.Security.Greenfield
.FirstOrDefaultAsync(applicationUser => .FirstOrDefaultAsync(applicationUser =>
applicationUser.NormalizedUserName == _userManager.NormalizeName(username)); applicationUser.NormalizedUserName == _userManager.NormalizeName(username));
if (!UserService.TryCanLogin(user, out var error))
{
return AuthenticateResult.Fail($"Basic authentication failed: {error}");
}
if (user.Fido2Credentials.Any()) if (user.Fido2Credentials.Any())
{ {
return AuthenticateResult.Fail("Cannot use Basic authentication with multi-factor is enabled."); 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.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using BTCPayServer.Services.Apps;
using BTCPayServer.Validation; using BTCPayServer.Validation;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -14,6 +14,19 @@ namespace BTCPayServer.Services
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Disable new user registration on the server")] [Display(Name = "Disable new user registration on the server")]
public bool LockSubscription { get; set; } 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)] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Discourage search engines from indexing this site")] [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")] [Display(Name = "Disable non-admins access to the user creation API endpoint")]
public bool DisableNonAdminCreateUserApi { get; set; } 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"; public const string DefaultPluginSource = "https://plugin-builder.btcpayserver.org";
[UriAttribute] [UriAttribute]

View file

@ -1,10 +1,13 @@
#nullable enable #nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using BTCPayServer.Storage.Services; using BTCPayServer.Storage.Services;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@ -20,6 +23,7 @@ namespace BTCPayServer.Services
private readonly StoredFileRepository _storedFileRepository; private readonly StoredFileRepository _storedFileRepository;
private readonly FileService _fileService; private readonly FileService _fileService;
private readonly StoreRepository _storeRepository; private readonly StoreRepository _storeRepository;
private readonly EventAggregator _eventAggregator;
private readonly ApplicationDbContextFactory _applicationDbContextFactory; private readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly ILogger<UserService> _logger; private readonly ILogger<UserService> _logger;
@ -27,6 +31,7 @@ namespace BTCPayServer.Services
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
StoredFileRepository storedFileRepository, StoredFileRepository storedFileRepository,
FileService fileService, FileService fileService,
EventAggregator eventAggregator,
StoreRepository storeRepository, StoreRepository storeRepository,
ApplicationDbContextFactory applicationDbContextFactory, ApplicationDbContextFactory applicationDbContextFactory,
ILogger<UserService> logger) ILogger<UserService> logger)
@ -34,6 +39,7 @@ namespace BTCPayServer.Services
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_storedFileRepository = storedFileRepository; _storedFileRepository = storedFileRepository;
_fileService = fileService; _fileService = fileService;
_eventAggregator = eventAggregator;
_storeRepository = storeRepository; _storeRepository = storeRepository;
_applicationDbContextFactory = applicationDbContextFactory; _applicationDbContextFactory = applicationDbContextFactory;
_logger = logger; _logger = logger;
@ -46,26 +52,89 @@ namespace BTCPayServer.Services
(userRole, role) => role.Name).ToArray()))).ToListAsync(); (userRole, role) => role.Name).ToArray()))).ToListAsync();
} }
public static ApplicationUserData FromModel(ApplicationUser data, string?[] roles) public static ApplicationUserData FromModel(ApplicationUser data, string?[] roles)
{ {
return new ApplicationUserData() return new ApplicationUserData
{ {
Id = data.Id, Id = data.Id,
Email = data.Email, Email = data.Email,
EmailConfirmed = data.EmailConfirmed, EmailConfirmed = data.EmailConfirmed,
RequiresEmailConfirmation = data.RequiresEmailConfirmation, RequiresEmailConfirmation = data.RequiresEmailConfirmation,
Approved = data.Approved,
RequiresApproval = data.RequiresApproval,
Created = data.Created, Created = data.Created,
Roles = roles, Roles = roles,
Disabled = data.LockoutEnabled && data.LockoutEnd is not null && DateTimeOffset.UtcNow < data.LockoutEnd.Value.UtcDateTime 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 && return user.LockoutEnabled && user.LockoutEnd is not null &&
DateTimeOffset.UtcNow < user.LockoutEnd.Value.UtcDateTime; 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) public async Task<bool?> ToggleUser(string userId, DateTimeOffset? lockedOutDeadline)
{ {
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
@ -163,7 +232,6 @@ namespace BTCPayServer.Services
} }
} }
public async Task<bool> IsUserTheOnlyOneAdmin(ApplicationUser user) public async Task<bool> IsUserTheOnlyOneAdmin(ApplicationUser user)
{ {
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
@ -175,7 +243,7 @@ namespace BTCPayServer.Services
} }
var adminUsers = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin); var adminUsers = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var enabledAdminUsers = adminUsers var enabledAdminUsers = adminUsers
.Where(applicationUser => !IsDisabled(applicationUser)) .Where(applicationUser => !IsDisabled(applicationUser) && IsApproved(applicationUser))
.Select(applicationUser => applicationUser.Id).ToList(); .Select(applicationUser => applicationUser.Id).ToList();
return enabledAdminUsers.Count == 1 && enabledAdminUsers.Contains(user.Id); return enabledAdminUsers.Count == 1 && enabledAdminUsers.Contains(user.Id);

View file

@ -10,7 +10,7 @@
</p> </p>
<form asp-action="ForgotPassword" method="post"> <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"> <div class="form-group">
<label asp-for="Email" class="form-label"></label> <label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" /> <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"; ViewData["Title"] = "Account disabled";
Layout = "_LayoutSignedOut"; Layout = "_LayoutSignedOut";

View file

@ -9,16 +9,16 @@
<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="login-form" asp-action="Login"> <form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="login-form" asp-action="Login">
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)"> <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"> <div class="form-group">
<label asp-for="Email" class="form-label"></label> <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> <span asp-validation-for="Email" class="text-danger"></span>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<label asp-for="Password" class="form-label"></label> <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>
<div class="input-group d-flex"> <div class="input-group d-flex">
<input asp-for="Password" class="form-control" required /> <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"> <form asp-route-returnUrl="@ViewData["ReturnUrl"]" asp-route-logon="true" method="post">
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)" > <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"> <div class="form-group">
<label asp-for="Email" class="form-label"></label> <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 />

View file

@ -13,9 +13,9 @@
<partial name="_ValidationScriptsPartial" /> <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) else if (Model.LoginWith2FaViewModel == null && Model.LoginWithFido2ViewModel == null && Model.LoginWithLNURLAuthViewModel == null)
{ {

View file

@ -5,7 +5,7 @@
} }
<form method="post" asp-action="SetPassword"> <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="Code" type="hidden"/>
<input asp-for="EmailSetInternally" type="hidden"/> <input asp-for="EmailSetInternally" type="hidden"/>
@if (Model.EmailSetInternally) @if (Model.EmailSetInternally)

View file

@ -30,11 +30,6 @@
<partial name="LayoutHead"/> <partial name="LayoutHead"/>
<link href="~/vendor/bootstrap-vue/bootstrap-vue.min.css" asp-append-version="true" rel="stylesheet"/> <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"/> <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> </head>
<body class="min-vh-100"> <body class="min-vh-100">
<div id="app" class="d-flex flex-column min-vh-100 pb-l"> <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> <span asp-validation-for="IsAdmin" class="text-danger"></span>
</div> </div>
} }
@if (ViewData["AllowRequestEmailConfirmation"] is true) @if (ViewData["AllowRequestEmailConfirmation"] is true)
{ {
<div class="form-group form-check"> <div class="form-group form-check">
@ -46,8 +44,16 @@
<span asp-validation-for="EmailConfirmed" class="text-danger"></span> <span asp-validation-for="EmailConfirmed" class="text-danger"></span>
</div> </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> </form>
</div> </div>
</div> </div>

View file

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

View file

@ -11,15 +11,10 @@
@section PageHeadContent { @section PageHeadContent {
<style> <style>
#AllowLightningInternalNodeForAll ~ .info-note, input[type="checkbox"] ~ .info-note,
#AllowHotWalletRPCImportForAll ~ .info-note, input[type="checkbox"] ~ .subsettings { display: none; }
#AllowHotWalletForAll ~ .info-note, input[type="checkbox"]:checked ~ .info-note { display: flex; max-width: 44em; }
#DisableNonAdminCreateUserApi:checked ~ .info-note, input[type="checkbox"]:checked ~ .subsettings { display: block; }
#LockSubscription:checked ~ .info-note { display: none; }
#AllowLightningInternalNodeForAll:checked ~ .info-note,
#AllowHotWalletRPCImportForAll:checked ~ .info-note,
#AllowHotWalletForAll:checked ~ .info-note { display: inline-flex; }
</style> </style>
} }
@ -31,10 +26,59 @@
} }
<div class="row"> <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"> <form method="post" class="d-flex flex-column">
<div class="form-group mb-5"> <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"> <div class="form-check my-3">
<input asp-for="AllowLightningInternalNodeForAll" type="checkbox" class="form-check-input"/> <input asp-for="AllowLightningInternalNodeForAll" type="checkbox" class="form-check-input"/>
<label asp-for="AllowLightningInternalNodeForAll" class="form-check-label"></label> <label asp-for="AllowLightningInternalNodeForAll" class="form-check-label"></label>
@ -70,49 +114,6 @@
</div> </div>
</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"> <div class="form-group mb-5">
<h4 class="mb-3">Email Settings</h4> <h4 class="mb-3">Email Settings</h4>
<div class="form-check my-3"> <div class="form-check my-3">
@ -181,7 +182,7 @@
<h4 class="mt-5">Customization Settings</h4> <h4 class="mt-5">Customization Settings</h4>
<div class="form-group mb-5"> <div class="form-group mb-5">
<label asp-for="RootAppId" class="form-label"></label> <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()) @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> <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> <label asp-for="DomainToAppMapping[index].AppId" class="form-label"></label>
<select asp-for="DomainToAppMapping[index].AppId" <select asp-for="DomainToAppMapping[index].AppId"
asp-items="@(new SelectList(ViewBag.AppsList, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.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> </select>
<span asp-validation-for="DomainToAppMapping[index].AppId" class="text-danger"></span> <span asp-validation-for="DomainToAppMapping[index].AppId" class="text-danger"></span>
</div> </div>

View file

@ -5,14 +5,26 @@
<h3 class="mb-4">@ViewData["Title"]</h3> <h3 class="mb-4">@ViewData["Title"]</h3>
<div class="row"> <form method="post">
<div class="col-md-8"> <div class="form-check my-3">
<form method="post"> <input asp-for="IsAdmin" type="checkbox" class="form-check-input" />
<div class="form-group form-check mb-4"> <label asp-for="IsAdmin" class="form-check-label">User is admin</label>
<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>
</div> </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; margin-bottom: 0;
} }
.no-marker > ul {
list-style-type: none;
padding-left: 0;
}
/* General and site-wide Bootstrap modifications */ /* General and site-wide Bootstrap modifications */
p { p {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;

View file

@ -255,7 +255,7 @@
"tags": [ "tags": [
"Users" "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.", "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": [ "parameters": [
{ {
@ -283,10 +283,63 @@
"description": "User has been successfully toggled" "description": "User has been successfully toggled"
}, },
"401": { "401": {
"description": "Missing authorization for deleting the user" "description": "Missing authorization for locking the user"
}, },
"403": { "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": { "404": {
"description": "User with provided ID was not found" "description": "User with provided ID was not found"
@ -325,7 +378,15 @@
}, },
"requiresEmailConfirmation": { "requiresEmailConfirmation": {
"type": "boolean", "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": { "created": {
"nullable": true, "nullable": true,
@ -355,6 +416,16 @@
"description": "Whether to lock or unlock the user" "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"
}
}
} }
} }
}, },