diff --git a/BTCPayServer.Client/BTCPayServerClient.Users.cs b/BTCPayServer.Client/BTCPayServerClient.Users.cs index 29ac0c3f8..b224d57f6 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Users.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Users.cs @@ -41,6 +41,14 @@ namespace BTCPayServer.Client return response.IsSuccessStatusCode; } + public virtual async Task 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 GetUsers(CancellationToken token = default) { var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token); diff --git a/BTCPayServer.Client/Models/ApplicationUserData.cs b/BTCPayServer.Client/Models/ApplicationUserData.cs index 43b8346b8..59aa784cc 100644 --- a/BTCPayServer.Client/Models/ApplicationUserData.cs +++ b/BTCPayServer.Client/Models/ApplicationUserData.cs @@ -25,6 +25,16 @@ namespace BTCPayServer.Client.Models /// public bool RequiresEmailConfirmation { get; set; } + /// + /// Whether the user was approved by an admin + /// + public bool Approved { get; set; } + + /// + /// whether the user needed approval on account creation + /// + public bool RequiresApproval { get; set; } + /// /// the roles of the user /// diff --git a/BTCPayServer.Client/Models/ApproveUserRequest.cs b/BTCPayServer.Client/Models/ApproveUserRequest.cs new file mode 100644 index 000000000..36faa5afb --- /dev/null +++ b/BTCPayServer.Client/Models/ApproveUserRequest.cs @@ -0,0 +1,6 @@ +namespace BTCPayServer.Client; + +public class ApproveUserRequest +{ + public bool Approved { get; set; } +} diff --git a/BTCPayServer.Data/Data/ApplicationUser.cs b/BTCPayServer.Data/Data/ApplicationUser.cs index 3a109ff4c..3cb2972fb 100644 --- a/BTCPayServer.Data/Data/ApplicationUser.cs +++ b/BTCPayServer.Data/Data/ApplicationUser.cs @@ -11,6 +11,8 @@ namespace BTCPayServer.Data public class ApplicationUser : IdentityUser, IHasBlob { public bool RequiresEmailConfirmation { get; set; } + public bool RequiresApproval { get; set; } + public bool Approved { get; set; } public List StoredFiles { get; set; } [Obsolete("U2F support has been replace with FIDO2")] public List U2FDevices { get; set; } diff --git a/BTCPayServer.Data/Migrations/20240104155620_AddApprovalToApplicationUser.cs b/BTCPayServer.Data/Migrations/20240104155620_AddApprovalToApplicationUser.cs new file mode 100644 index 000000000..adf277d67 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20240104155620_AddApprovalToApplicationUser.cs @@ -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( + name: "Approved", + table: "AspNetUsers", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + 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"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index bcc6343b0..474086428 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -112,6 +112,9 @@ namespace BTCPayServer.Migrations b.Property("AccessFailedCount") .HasColumnType("INTEGER"); + b.Property("Approved") + .HasColumnType("INTEGER"); + b.Property("Blob") .HasColumnType("BLOB"); @@ -158,6 +161,9 @@ namespace BTCPayServer.Migrations b.Property("PhoneNumberConfirmed") .HasColumnType("INTEGER"); + b.Property("RequiresApproval") + .HasColumnType("INTEGER"); + b.Property("RequiresEmailConfirmation") .HasColumnType("INTEGER"); diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index 55a3e4b0d..f22e6801d 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -245,6 +245,9 @@ namespace BTCPayServer.Tests rateProvider.Providers.Add("kraken", kraken); } + // reset test server policies + var settings = GetService(); + await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = false }); TestLogs.LogInformation("Waiting site is operational..."); await WaitSiteIsOperational(); diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 52c6bef3f..2ec3aac50 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -694,14 +694,10 @@ namespace BTCPayServer.Tests // Try loading 1 user by email. Loading myself. await AssertHttpError(403, async () => await badClient.GetUserByIdOrEmail(badUser.Email)); - - - // Why is this line needed? I saw it in "CanDeleteUsersViaApi" as well. Is this part of the cleanup? tester.Stores.Remove(adminUser.StoreId); } - [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task CanCreateUsersViaAPI() @@ -3571,6 +3567,78 @@ namespace BTCPayServer.Tests await newUserBasicClient.GetCurrentUser(); } + [Fact(Timeout = 60 * 2 * 1000)] + [Trait("Integration", "Integration")] + public async Task ApproveUserTests() + { + using var tester = CreateServerTester(); + await tester.StartAsync(); + var admin = tester.NewAccount(); + await admin.GrantAccessAsync(true); + var adminClient = await admin.CreateClient(Policies.Unrestricted); + Assert.False((await adminClient.GetUserByIdOrEmail(admin.UserId)).RequiresApproval); + Assert.Empty(await adminClient.GetNotifications()); + + // require approval + var settings = tester.PayTester.GetService(); + await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = true }); + + // new user needs approval + var unapprovedUser = tester.NewAccount(); + await unapprovedUser.GrantAccessAsync(); + var unapprovedUserBasicAuthClient = await unapprovedUser.CreateClient(); + await AssertAPIError("unauthenticated", async () => + { + await unapprovedUserBasicAuthClient.GetCurrentUser(); + }); + var unapprovedUserApiKeyClient = await unapprovedUser.CreateClient(Policies.Unrestricted); + await AssertAPIError("unauthenticated", async () => + { + await unapprovedUserApiKeyClient.GetCurrentUser(); + }); + Assert.True((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).RequiresApproval); + Assert.False((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved); + Assert.Single(await adminClient.GetNotifications(false)); + + // approve + Assert.True(await adminClient.ApproveUser(unapprovedUser.UserId, true, CancellationToken.None)); + Assert.True((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved); + Assert.True((await unapprovedUserApiKeyClient.GetCurrentUser()).Approved); + Assert.True((await unapprovedUserBasicAuthClient.GetCurrentUser()).Approved); + + // un-approve + Assert.True(await adminClient.ApproveUser(unapprovedUser.UserId, false, CancellationToken.None)); + Assert.False((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved); + await AssertAPIError("unauthenticated", async () => + { + await unapprovedUserApiKeyClient.GetCurrentUser(); + }); + await AssertAPIError("unauthenticated", async () => + { + await unapprovedUserBasicAuthClient.GetCurrentUser(); + }); + + // reset policies to not require approval + await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = false }); + + // new user does not need approval + var newUser = tester.NewAccount(); + await newUser.GrantAccessAsync(); + var newUserBasicAuthClient = await newUser.CreateClient(); + var newUserApiKeyClient = await newUser.CreateClient(Policies.Unrestricted); + Assert.False((await newUserApiKeyClient.GetCurrentUser()).RequiresApproval); + Assert.False((await newUserApiKeyClient.GetCurrentUser()).Approved); + Assert.False((await newUserBasicAuthClient.GetCurrentUser()).RequiresApproval); + Assert.False((await newUserBasicAuthClient.GetCurrentUser()).Approved); + Assert.Single(await adminClient.GetNotifications(false)); + + // try unapproving user which does not have the RequiresApproval flag + await AssertAPIError("invalid-state", async () => + { + await adminClient.ApproveUser(newUser.UserId, false, CancellationToken.None); + }); + } + [Fact(Timeout = 60 * 2 * 1000)] [Trait("Integration", "Integration")] [Trait("Lightning", "Lightning")] diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 0dfce128a..09662aab9 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Models; using BTCPayServer.Client.Models; using BTCPayServer.Lightning; using BTCPayServer.Lightning.CLightning; +using BTCPayServer.Services; using BTCPayServer.Views.Manage; using BTCPayServer.Views.Server; using BTCPayServer.Views.Stores; @@ -76,6 +77,7 @@ namespace BTCPayServer.Tests // A bit less than test timeout TimeSpan.FromSeconds(50)); } + ServerUri = Server.PayTester.ServerUri; Driver.Manage().Window.Maximize(); diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 59b13888c..8175aa20a 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -405,6 +405,148 @@ namespace BTCPayServer.Tests Assert.Contains("/login", s.Driver.Url); } + [Fact(Timeout = TestTimeout)] + public async Task CanRequireApprovalForNewAccounts() + { + using var s = CreateSeleniumTester(); + await s.StartAsync(); + + var settings = s.Server.PayTester.GetService(); + var policies = await settings.GetSettingAsync() ?? new PoliciesSettings(); + Assert.True(policies.EnableRegistration); + Assert.False(policies.RequiresUserApproval); + + // Register admin and adapt policies + s.RegisterNewUser(true); + var admin = s.AsTestAccount(); + s.GoToHome(); + s.GoToServer(ServerNavPages.Policies); + Assert.True(s.Driver.FindElement(By.Id("EnableRegistration")).Selected); + Assert.False(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected); + s.Driver.FindElement(By.Id("RequiresUserApproval")).Click(); + s.Driver.FindElement(By.Id("SaveButton")).Click(); + Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text); + Assert.True(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected); + + // Check user create view has approval checkbox + s.GoToServer(ServerNavPages.Users); + s.Driver.FindElement(By.Id("CreateUser")).Click(); + Assert.False(s.Driver.FindElement(By.Id("Approved")).Selected); + + // Ensure there is no unread notification yet + s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge")); + s.Logout(); + + // Register user and try to log in + s.GoToRegister(); + s.RegisterNewUser(); + s.Driver.AssertNoError(); + Assert.Contains("Account created. The new account requires approval by an admin before you can log in", s.FindAlertMessage().Text); + Assert.Contains("/login", s.Driver.Url); + + var unapproved = s.AsTestAccount(); + s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password); + Assert.Contains("Your user account requires approval by an admin before you can log in", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning).Text); + Assert.Contains("/login", s.Driver.Url); + + // Login with admin + s.GoToLogin(); + s.LogIn(admin.RegisterDetails.Email, admin.RegisterDetails.Password); + s.GoToHome(); + + // Check notification + TestUtils.Eventually(() => Assert.Equal("1", s.Driver.FindElement(By.Id("NotificationsBadge")).Text)); + s.Driver.FindElement(By.Id("NotificationsHandle")).Click(); + Assert.Matches($"New user {unapproved.RegisterDetails.Email} requires approval", s.Driver.FindElement(By.CssSelector("#NotificationsList .notification")).Text); + s.Driver.FindElement(By.Id("NotificationsMarkAllAsSeen")).Click(); + + // Reset approval policy + s.GoToServer(ServerNavPages.Policies); + Assert.True(s.Driver.FindElement(By.Id("EnableRegistration")).Selected); + Assert.True(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected); + s.Driver.FindElement(By.Id("RequiresUserApproval")).Click(); + s.Driver.FindElement(By.Id("SaveButton")).Click(); + Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text); + Assert.False(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected); + + // Check user create view does not have approval checkbox + s.GoToServer(ServerNavPages.Users); + s.Driver.FindElement(By.Id("CreateUser")).Click(); + s.Driver.ElementDoesNotExist(By.Id("Approved")); + + s.Logout(); + + // Still requires approval for user who registered before + s.GoToLogin(); + s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password); + Assert.Contains("Your user account requires approval by an admin before you can log in", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning).Text); + Assert.Contains("/login", s.Driver.Url); + + // New user can register and gets in without approval + s.GoToRegister(); + s.RegisterNewUser(); + s.Driver.AssertNoError(); + Assert.DoesNotContain("/login", s.Driver.Url); + var autoApproved = s.AsTestAccount(); + s.CreateNewStore(); + s.Logout(); + + // Login with admin and check list + s.GoToLogin(); + s.LogIn(admin.RegisterDetails.Email, admin.RegisterDetails.Password); + s.GoToHome(); + + // No notification this time + s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge")); + + // Check users list + s.GoToServer(ServerNavPages.Users); + var rows = s.Driver.FindElements(By.CssSelector("#UsersList tr")); + Assert.True(rows.Count >= 3); + + // Check user which didn't require approval + s.Driver.FindElement(By.Id("SearchTerm")).Clear(); + s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(autoApproved.RegisterDetails.Email); + s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter); + rows = s.Driver.FindElements(By.CssSelector("#UsersList tr")); + Assert.Single(rows); + Assert.Contains(autoApproved.RegisterDetails.Email, rows.First().Text); + s.Driver.ElementDoesNotExist(By.CssSelector("#UsersList tr:first-child .user-approved")); + // Edit view does not contain approve toggle + s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-edit")).Click(); + s.Driver.ElementDoesNotExist(By.Id("Approved")); + + // Check user which still requires approval + s.GoToServer(ServerNavPages.Users); + s.Driver.FindElement(By.Id("SearchTerm")).Clear(); + s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(unapproved.RegisterDetails.Email); + s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter); + rows = s.Driver.FindElements(By.CssSelector("#UsersList tr")); + Assert.Single(rows); + Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text); + Assert.Contains("Pending Approval", s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-status")).Text); + // Approve user + s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-edit")).Click(); + s.Driver.FindElement(By.Id("Approved")).Click(); + s.Driver.FindElement(By.Id("SaveUser")).Click(); + Assert.Contains("User successfully updated", s.FindAlertMessage().Text); + // Check list again + s.GoToServer(ServerNavPages.Users); + Assert.Contains(unapproved.RegisterDetails.Email, s.Driver.FindElement(By.Id("SearchTerm")).GetAttribute("value")); + rows = s.Driver.FindElements(By.CssSelector("#UsersList tr")); + Assert.Single(rows); + Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text); + Assert.Contains("Active", s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-status")).Text); + + // Finally, login user that needed approval + s.Logout(); + s.GoToLogin(); + s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password); + s.Driver.AssertNoError(); + Assert.DoesNotContain("/login", s.Driver.Url); + s.CreateNewStore(); + } + [Fact(Timeout = TestTimeout)] public async Task CanUseSSHService() { diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 641b0dd74..d24ff125f 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -2325,17 +2325,21 @@ namespace BTCPayServer.Tests using var tester = CreateServerTester(newDb: true); await tester.StartAsync(); var f = tester.PayTester.GetService(); + const string id = "BTCPayServer.Services.PoliciesSettings"; using (var ctx = f.CreateContext()) { - var setting = new SettingData() { Id = "BTCPayServer.Services.PoliciesSettings" }; - setting.Value = JObject.Parse("{\"RootAppId\": null, \"RootAppType\": 1, \"Experimental\": false, \"PluginSource\": null, \"LockSubscription\": false, \"DisableSSHService\": false, \"PluginPreReleases\": false, \"BlockExplorerLinks\": [],\"DomainToAppMapping\": [{\"AppId\": \"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\", \"Domain\": \"donate.nicolas-dorier.com\", \"AppType\": 0}], \"CheckForNewVersions\": false, \"AllowHotWalletForAll\": false, \"RequiresConfirmedEmail\": false, \"DiscourageSearchEngines\": false, \"DisableInstantNotifications\": false, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString(); + // remove existing policies setting + var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == id); + if (setting != null) ctx.Settings.Remove(setting); + // create legacy policies setting that needs migration + setting = new SettingData { Id = id, Value = JObject.Parse("{\"RootAppId\": null, \"RootAppType\": 1, \"Experimental\": false, \"PluginSource\": null, \"LockSubscription\": false, \"DisableSSHService\": false, \"PluginPreReleases\": false, \"BlockExplorerLinks\": [],\"DomainToAppMapping\": [{\"AppId\": \"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\", \"Domain\": \"donate.nicolas-dorier.com\", \"AppType\": 0}], \"CheckForNewVersions\": false, \"AllowHotWalletForAll\": false, \"RequiresConfirmedEmail\": false, \"DiscourageSearchEngines\": false, \"DisableInstantNotifications\": false, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString() }; ctx.Settings.Add(setting); await ctx.SaveChangesAsync(); } await RestartMigration(tester); using (var ctx = f.CreateContext()) { - var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == "BTCPayServer.Services.PoliciesSettings"); + var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == id); var o = JObject.Parse(setting.Value); Assert.Equal("Crowdfund", o["RootAppType"].Value()); o = (JObject)((JArray)o["DomainToAppMapping"])[0]; diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs index b14f612ba..e829f4870 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs @@ -92,6 +92,26 @@ namespace BTCPayServer.Controllers.Greenfield $"{(request.Locked ? "Locking" : "Unlocking")} user failed"); } + [Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPost("~/api/v1/users/{idOrEmail}/approve")] + public async Task ApproveUser(string idOrEmail, ApproveUserRequest request) + { + var user = await _userManager.FindByIdOrEmail(idOrEmail); + if (user is null) + { + return this.UserNotFound(); + } + + var success = false; + if (user.RequiresApproval) + { + success = await _userService.SetUserApproval(user.Id, request.Approved, Request.GetAbsoluteRootUri()); + } + + return success ? Ok() : this.CreateAPIError("invalid-state", + $"{(request.Approved ? "Approving" : "Unapproving")} user failed"); + } + [Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpGet("~/api/v1/users/")] public async Task> GetUsers() @@ -163,7 +183,9 @@ namespace BTCPayServer.Controllers.Greenfield UserName = request.Email, Email = request.Email, RequiresEmailConfirmation = policies.RequiresConfirmedEmail, + RequiresApproval = policies.RequiresUserApproval, Created = DateTimeOffset.UtcNow, + Approved = !anyAdmin && isAdmin // auto-approve first admin }; var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password); if (!passwordValidation.Succeeded) diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs index fcb46c298..8d1d83e56 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -1084,6 +1084,13 @@ namespace BTCPayServer.Controllers.Greenfield new LockUserRequest { Locked = disabled })); } + public override async Task ApproveUser(string idOrEmail, bool approved, CancellationToken token = default) + { + return GetFromActionResult( + await GetController().ApproveUser(idOrEmail, + new ApproveUserRequest { Approved = approved })); + } + public override async Task PatchOnChainWalletTransaction(string storeId, string cryptoCode, string transactionId, PatchOnChainTransactionRequest request, bool force = false, CancellationToken token = default) diff --git a/BTCPayServer/Controllers/UIAccountController.cs b/BTCPayServer/Controllers/UIAccountController.cs index 35f30d71b..e892defc8 100644 --- a/BTCPayServer/Controllers/UIAccountController.cs +++ b/BTCPayServer/Controllers/UIAccountController.cs @@ -2,7 +2,6 @@ using System; using System.Globalization; using System.Linq; using System.Net.Http; -using System.Security.Claims; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Extensions; @@ -109,6 +108,7 @@ namespace BTCPayServer.Controllers { if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl)) return RedirectToLocal(); + // Clear the existing external cookie to ensure a clean login process await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); @@ -118,15 +118,13 @@ namespace BTCPayServer.Controllers } ViewData["ReturnUrl"] = returnUrl; - return View(nameof(Login), new LoginViewModel() { Email = email }); + return View(nameof(Login), new LoginViewModel { Email = email }); } - [HttpPost("/login/code")] [AllowAnonymous] [ValidateAntiForgeryToken] [RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)] - public async Task LoginWithCode(string loginCode, string returnUrl = null) { if (!string.IsNullOrEmpty(loginCode)) @@ -134,17 +132,26 @@ namespace BTCPayServer.Controllers var userId = _userLoginCodeService.Verify(loginCode); if (userId is null) { - ModelState.AddModelError(string.Empty, - "Login code was invalid"); - return await Login(returnUrl, null); + TempData[WellKnownTempData.ErrorMessage] = "Login code was invalid"; + return await Login(returnUrl); } - var user = await _userManager.FindByIdAsync(userId); - _logger.LogInformation("User with ID {UserId} logged in with a login code.", user.Id); + var user = await _userManager.FindByIdAsync(userId); + if (!UserService.TryCanLogin(user, out var message)) + { + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning, + Message = message + }); + return await Login(returnUrl); + } + + _logger.LogInformation("User with ID {UserId} logged in with a login code", user!.Id); await _signInManager.SignInAsync(user, false, "LoginCode"); return RedirectToLocal(returnUrl); } - return await Login(returnUrl, null); + return await Login(returnUrl); } [HttpPost("/login")] @@ -161,24 +168,20 @@ namespace BTCPayServer.Controllers ViewData["ReturnUrl"] = returnUrl; if (ModelState.IsValid) { - // Require the user to have a confirmed email before they can log on. + // Require the user to pass basic checks (approval, confirmed email, not disabled) before they can log on var user = await _userManager.FindByEmailAsync(model.Email); - if (user != null) + const string errorMessage = "Invalid login attempt."; + if (!UserService.TryCanLogin(user, out var message)) { - if (user.RequiresEmailConfirmation && !await _userManager.IsEmailConfirmedAsync(user)) + TempData.SetStatusMessageModel(new StatusMessageModel { - ModelState.AddModelError(string.Empty, - "You must have a confirmed email to log in."); - return View(model); - } - } - else - { - ModelState.AddModelError(string.Empty, "Invalid login attempt."); + Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning, + Message = message + }); return View(model); } - var fido2Devices = await _fido2Service.HasCredentials(user.Id); + var fido2Devices = await _fido2Service.HasCredentials(user!.Id); var lnurlAuthCredentials = await _lnurlAuthService.HasCredentials(user.Id); if (!await _userManager.IsLockedOutAsync(user) && (fido2Devices || lnurlAuthCredentials)) { @@ -196,33 +199,30 @@ namespace BTCPayServer.Controllers }; } - return View("SecondaryLogin", new SecondaryLoginViewModel() + return View("SecondaryLogin", new SecondaryLoginViewModel { LoginWith2FaViewModel = twoFModel, LoginWithFido2ViewModel = fido2Devices ? await BuildFido2ViewModel(model.RememberMe, user) : null, LoginWithLNURLAuthViewModel = lnurlAuthCredentials ? await BuildLNURLAuthViewModel(model.RememberMe, user) : null, }); } - else - { - await _userManager.AccessFailedAsync(user); - ModelState.AddModelError(string.Empty, "Invalid login attempt."); - return View(model); - } + await _userManager.AccessFailedAsync(user); + ModelState.AddModelError(string.Empty, errorMessage!); + return View(model); } var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true); if (result.Succeeded) { - _logger.LogInformation($"User '{user.Id}' logged in."); + _logger.LogInformation("User {UserId} logged in", user.Id); return RedirectToLocal(returnUrl); } if (result.RequiresTwoFactor) { - return View("SecondaryLogin", new SecondaryLoginViewModel() + return View("SecondaryLogin", new SecondaryLoginViewModel { - LoginWith2FaViewModel = new LoginWith2faViewModel() + LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = model.RememberMe } @@ -230,14 +230,12 @@ namespace BTCPayServer.Controllers } if (result.IsLockedOut) { - _logger.LogWarning($"User '{user.Id}' account locked out."); + _logger.LogWarning("User {UserId} account locked out", user.Id); return RedirectToAction(nameof(Lockout), new { user.LockoutEnd }); } - else - { - ModelState.AddModelError(string.Empty, "Invalid login attempt."); - return View(model); - } + + ModelState.AddModelError(string.Empty, errorMessage); + return View(model); } // If we got this far, something failed, redisplay form @@ -253,7 +251,7 @@ namespace BTCPayServer.Controllers { return null; } - return new LoginWithFido2ViewModel() + return new LoginWithFido2ViewModel { Data = r, UserId = user.Id, @@ -263,7 +261,6 @@ namespace BTCPayServer.Controllers return null; } - private async Task BuildLNURLAuthViewModel(bool rememberMe, ApplicationUser user) { if (_btcPayServerEnvironment.IsSecure(HttpContext)) @@ -273,15 +270,14 @@ namespace BTCPayServer.Controllers { return null; } - return new LoginWithLNURLAuthViewModel() + return new LoginWithLNURLAuthViewModel { - RememberMe = rememberMe, UserId = user.Id, LNURLEndpoint = new Uri(_linkGenerator.GetUriByAction( - action: nameof(UILNURLAuthController.LoginResponse), - controller: "UILNURLAuth", - values: new { userId = user.Id, action = "login", tag = "login", k1 = Encoders.Hex.EncodeData(r) }, Request.Scheme, Request.Host, Request.PathBase)) + action: nameof(UILNURLAuthController.LoginResponse), + controller: "UILNURLAuth", + values: new { userId = user.Id, action = "login", tag = "login", k1 = Encoders.Hex.EncodeData(r) }, Request.Scheme, Request.Host, Request.PathBase) ?? string.Empty) }; } return null; @@ -298,14 +294,18 @@ namespace BTCPayServer.Controllers } ViewData["ReturnUrl"] = returnUrl; + var errorMessage = "Invalid login attempt."; var user = await _userManager.FindByIdAsync(viewModel.UserId); - - if (user == null) + if (!UserService.TryCanLogin(user, out var message)) { - return NotFound(); + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning, + Message = message + }); + return RedirectToAction("Login"); } - var errorMessage = string.Empty; try { var k1 = Encoders.Hex.DecodeData(viewModel.LNURLEndpoint.ParseQueryString().Get("k1")); @@ -313,34 +313,33 @@ namespace BTCPayServer.Controllers storedk1.SequenceEqual(k1)) { _lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out _); - await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2"); - _logger.LogInformation("User logged in."); + await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2"); + _logger.LogInformation("User logged in"); return RedirectToLocal(returnUrl); } - - errorMessage = "Invalid login attempt."; } catch (Exception e) { errorMessage = e.Message; } - ModelState.AddModelError(string.Empty, errorMessage); - return View("SecondaryLogin", new SecondaryLoginViewModel() + if (!string.IsNullOrEmpty(errorMessage)) { - - LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null, + ModelState.AddModelError(string.Empty, errorMessage); + } + return View("SecondaryLogin", new SecondaryLoginViewModel + { + LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user!.Id) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null, LoginWithLNURLAuthViewModel = viewModel, LoginWith2FaViewModel = !user.TwoFactorEnabled ? null - : new LoginWith2faViewModel() + : new LoginWith2faViewModel { RememberMe = viewModel.RememberMe } }); } - [HttpPost("/login/fido2")] [AllowAnonymous] [ValidateAntiForgeryToken] @@ -352,44 +351,50 @@ namespace BTCPayServer.Controllers } ViewData["ReturnUrl"] = returnUrl; + var errorMessage = "Invalid login attempt."; var user = await _userManager.FindByIdAsync(viewModel.UserId); - - if (user == null) + if (!UserService.TryCanLogin(user, out var message)) { - return NotFound(); + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning, + Message = message + }); + return RedirectToAction("Login"); } - var errorMessage = string.Empty; try { if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject())) { - await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2"); - _logger.LogInformation("User logged in."); + await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2"); + _logger.LogInformation("User logged in"); return RedirectToLocal(returnUrl); } - - errorMessage = "Invalid login attempt."; } catch (Fido2VerificationException e) { errorMessage = e.Message; } - ModelState.AddModelError(string.Empty, errorMessage); + if (!string.IsNullOrEmpty(errorMessage)) + { + ModelState.AddModelError(string.Empty, errorMessage); + } viewModel.Response = null; - return View("SecondaryLogin", new SecondaryLoginViewModel() + return View("SecondaryLogin", new SecondaryLoginViewModel { LoginWithFido2ViewModel = viewModel, - LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null, + LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user!.Id) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null, LoginWith2FaViewModel = !user.TwoFactorEnabled ? null - : new LoginWith2faViewModel() + : new LoginWith2faViewModel { RememberMe = viewModel.RememberMe } }); } + [HttpGet("/login/2fa")] [AllowAnonymous] public async Task LoginWith2fa(bool rememberMe, string returnUrl = null) @@ -401,7 +406,6 @@ namespace BTCPayServer.Controllers // Ensure the user has gone through the username & password screen first var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); - if (user == null) { throw new ApplicationException($"Unable to load two-factor authentication user."); @@ -409,11 +413,11 @@ namespace BTCPayServer.Controllers ViewData["ReturnUrl"] = returnUrl; - return View("SecondaryLogin", new SecondaryLoginViewModel() + return View("SecondaryLogin", new SecondaryLoginViewModel { LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe }, - LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null, - LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(rememberMe, user) : null, + LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null, + LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null, }); } @@ -437,32 +441,32 @@ namespace BTCPayServer.Controllers { throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); } + if (!UserService.TryCanLogin(user, out var message)) + { + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Warning, + Message = message + }); + return View(model); + } var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty, StringComparison.InvariantCulture).Replace("-", string.Empty, StringComparison.InvariantCulture); - var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine); - if (result.Succeeded) { - _logger.LogInformation("User with ID {UserId} logged in with 2fa.", user.Id); + _logger.LogInformation("User with ID {UserId} logged in with 2fa", user.Id); return RedirectToLocal(returnUrl); } - else if (result.IsLockedOut) + + _logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}", user.Id); + ModelState.AddModelError(string.Empty, "Invalid authenticator code."); + return View("SecondaryLogin", new SecondaryLoginViewModel { - _logger.LogWarning("User with ID {UserId} account locked out.", user.Id); - return RedirectToAction(nameof(Lockout), new { user.LockoutEnd }); - } - else - { - _logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}.", user.Id); - ModelState.AddModelError(string.Empty, "Invalid authenticator code."); - return View("SecondaryLogin", new SecondaryLoginViewModel() - { - LoginWith2FaViewModel = model, - LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null, - LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(rememberMe, user) : null, - }); - } + LoginWith2FaViewModel = model, + LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null, + LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null, + }); } [HttpGet("/login/recovery-code")] @@ -504,30 +508,35 @@ namespace BTCPayServer.Controllers var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); if (user == null) { - throw new ApplicationException($"Unable to load two-factor authentication user."); + throw new ApplicationException("Unable to load two-factor authentication user."); + } + if (!UserService.TryCanLogin(user, out var message)) + { + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Warning, + Message = message + }); + return View(model); } var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty, StringComparison.InvariantCulture); - var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); - if (result.Succeeded) { - _logger.LogInformation("User with ID {UserId} logged in with a recovery code.", user.Id); + _logger.LogInformation("User with ID {UserId} logged in with a recovery code", user.Id); return RedirectToLocal(returnUrl); } if (result.IsLockedOut) { - _logger.LogWarning("User with ID {UserId} account locked out.", user.Id); + _logger.LogWarning("User with ID {UserId} account locked out", user.Id); return RedirectToAction(nameof(Lockout), new { user.LockoutEnd }); } - else - { - _logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id); - ModelState.AddModelError(string.Empty, "Invalid recovery code entered."); - return View(); - } + + _logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id); + ModelState.AddModelError(string.Empty, "Invalid recovery code entered."); + return View(); } [HttpGet("/login/lockout")] @@ -540,7 +549,7 @@ namespace BTCPayServer.Controllers [HttpGet("/register")] [AllowAnonymous] [RateLimitsFilter(ZoneLimits.Register, Scope = RateLimitsScope.RemoteAddress)] - public IActionResult Register(string returnUrl = null, bool logon = true) + public IActionResult Register(string returnUrl = null) { if (!CanLoginOrRegister()) { @@ -567,32 +576,36 @@ namespace BTCPayServer.Controllers var policies = await _SettingsRepository.GetSettingAsync() ?? new PoliciesSettings(); if (policies.LockSubscription && !User.IsInRole(Roles.ServerAdmin)) return RedirectToAction(nameof(UIHomeController.Index), "UIHome"); + if (ModelState.IsValid) { + var anyAdmin = (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any(); + var isFirstAdmin = !anyAdmin || (model.IsAdmin && _Options.CheatMode); var user = new ApplicationUser { UserName = model.Email, Email = model.Email, RequiresEmailConfirmation = policies.RequiresConfirmedEmail, - Created = DateTimeOffset.UtcNow + RequiresApproval = policies.RequiresUserApproval, + Created = DateTimeOffset.UtcNow, + Approved = isFirstAdmin // auto-approve first admin }; var result = await _userManager.CreateAsync(user, model.Password); if (result.Succeeded) { - var admin = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin); - if (admin.Count == 0 || (model.IsAdmin && _Options.CheatMode)) + if (isFirstAdmin) { await _RoleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin)); await _userManager.AddToRoleAsync(user, Roles.ServerAdmin); - var settings = await _SettingsRepository.GetSettingAsync(); + var settings = await _SettingsRepository.GetSettingAsync() ?? new ThemeSettings(); settings.FirstRun = false; - await _SettingsRepository.UpdateSetting(settings); + await _SettingsRepository.UpdateSetting(settings); await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs); RegisteredAdmin = true; } - _eventAggregator.Publish(new UserRegisteredEvent() + _eventAggregator.Publish(new UserRegisteredEvent { RequestUri = Request.GetAbsoluteRootUri(), User = user, @@ -600,19 +613,29 @@ namespace BTCPayServer.Controllers }); RegisteredUserId = user.Id; - if (!policies.RequiresConfirmedEmail) + TempData[WellKnownTempData.SuccessMessage] = "Account created."; + if (policies.RequiresConfirmedEmail) { - if (logon) - await _signInManager.SignInAsync(user, isPersistent: false); + TempData[WellKnownTempData.SuccessMessage] += " Please confirm your email."; + } + if (policies.RequiresUserApproval) + { + TempData[WellKnownTempData.SuccessMessage] += " The new account requires approval by an admin before you can log in."; + } + if (policies.RequiresConfirmedEmail || policies.RequiresUserApproval) + { + return RedirectToAction(nameof(Login)); + } + if (logon) + { + await _signInManager.SignInAsync(user, isPersistent: false); return RedirectToLocal(returnUrl); } - else - { - TempData[WellKnownTempData.SuccessMessage] = "Account created, please confirm your email"; - return View(); - } } - AddErrors(result); + else + { + AddErrors(result); + } } // If we got this far, something failed, redisplay form @@ -628,8 +651,8 @@ namespace BTCPayServer.Controllers { await _signInManager.SignOutAsync(); HttpContext.DeleteUserPrefsCookie(); - _logger.LogInformation("User logged out."); - return RedirectToAction(nameof(UIAccountController.Login)); + _logger.LogInformation("User logged out"); + return RedirectToAction(nameof(Login)); } [HttpGet("/register/confirm-email")] @@ -650,7 +673,7 @@ namespace BTCPayServer.Controllers if (!await _userManager.HasPasswordAsync(user)) { - TempData.SetStatusMessageModel(new StatusMessageModel() + TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Info, Message = "Your email has been confirmed but you still need to set your password." @@ -660,7 +683,7 @@ namespace BTCPayServer.Controllers if (result.Succeeded) { - TempData.SetStatusMessageModel(new StatusMessageModel() + TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Success, Message = "Your email has been confirmed." @@ -687,12 +710,12 @@ namespace BTCPayServer.Controllers if (ModelState.IsValid) { var user = await _userManager.FindByEmailAsync(model.Email); - if (user == null || (user.RequiresEmailConfirmation && !(await _userManager.IsEmailConfirmedAsync(user)))) + if (!UserService.TryCanLogin(user, out _)) { // Don't reveal that the user does not exist or is not confirmed return RedirectToAction(nameof(ForgotPasswordConfirmation)); } - _eventAggregator.Publish(new UserPasswordResetRequestedEvent() + _eventAggregator.Publish(new UserPasswordResetRequestedEvent { User = user, RequestUri = Request.GetAbsoluteRootUri() @@ -740,16 +763,16 @@ namespace BTCPayServer.Controllers return View(model); } var user = await _userManager.FindByEmailAsync(model.Email); - if (user == null) + if (!UserService.TryCanLogin(user, out _)) { // Don't reveal that the user does not exist return RedirectToAction(nameof(Login)); } - var result = await _userManager.ResetPasswordAsync(user, model.Code, model.Password); + var result = await _userManager.ResetPasswordAsync(user!, model.Code, model.Password); if (result.Succeeded) { - TempData.SetStatusMessageModel(new StatusMessageModel() + TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Success, Message = "Password successfully set." @@ -800,7 +823,7 @@ namespace BTCPayServer.Controllers private void SetInsecureFlags() { - TempData.SetStatusMessageModel(new StatusMessageModel() + TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Error, Message = "You cannot login over an insecure connection. Please use HTTPS or Tor." diff --git a/BTCPayServer/Controllers/UIServerController.Users.cs b/BTCPayServer/Controllers/UIServerController.Users.cs index fa52f25f1..a6ff2246c 100644 --- a/BTCPayServer/Controllers/UIServerController.Users.cs +++ b/BTCPayServer/Controllers/UIServerController.Users.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; @@ -8,25 +7,21 @@ using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Models; using BTCPayServer.Data; using BTCPayServer.Events; -using BTCPayServer.Models; using BTCPayServer.Models.ServerViewModels; using BTCPayServer.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; -using MimeKit; namespace BTCPayServer.Controllers { public partial class UIServerController { - [Route("server/users")] + [HttpGet("server/users")] public async Task ListUsers( [FromServices] RoleManager roleManager, - UsersViewModel model, - string sortOrder = null - ) + UsersViewModel model, + string sortOrder = null) { model = this.ParseListQuery(model ?? new UsersViewModel()); @@ -64,7 +59,8 @@ namespace BTCPayServer.Controllers Name = u.UserName, Email = u.Email, Id = u.Id, - Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation, + EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null, + Approved = u.RequiresApproval ? u.Approved : null, Created = u.Created, Roles = u.UserRoles.Select(role => role.RoleId), Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime @@ -74,44 +70,67 @@ namespace BTCPayServer.Controllers return View(model); } - [Route("server/users/{userId}")] + [HttpGet("server/users/{userId}")] public new async Task User(string userId) { var user = await _UserManager.FindByIdAsync(userId); if (user == null) return NotFound(); var roles = await _UserManager.GetRolesAsync(user); - var userVM = new UsersViewModel.UserViewModel + var model = new UsersViewModel.UserViewModel { Id = user.Id, Email = user.Email, - Verified = user.EmailConfirmed || !user.RequiresEmailConfirmation, + EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null, + Approved = user.RequiresApproval ? user.Approved : null, IsAdmin = Roles.HasServerAdmin(roles) }; - return View(userVM); + return View(model); } - [Route("server/users/{userId}")] - [HttpPost] + [HttpPost("server/users/{userId}")] public new async Task User(string userId, UsersViewModel.UserViewModel viewModel) { var user = await _UserManager.FindByIdAsync(userId); if (user == null) return NotFound(); + bool? propertiesChanged = null; + bool? adminStatusChanged = null; + bool? approvalStatusChanged = null; + + if (user.RequiresApproval && viewModel.Approved.HasValue) + { + approvalStatusChanged = await _userService.SetUserApproval(user.Id, viewModel.Approved.Value, Request.GetAbsoluteRootUri()); + } + if (user.RequiresEmailConfirmation && viewModel.EmailConfirmed.HasValue && user.EmailConfirmed != viewModel.EmailConfirmed) + { + user.EmailConfirmed = viewModel.EmailConfirmed.Value; + propertiesChanged = true; + } + var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin); var roles = await _UserManager.GetRolesAsync(user); var wasAdmin = Roles.HasServerAdmin(roles); if (!viewModel.IsAdmin && admins.Count == 1 && wasAdmin) { TempData[WellKnownTempData.ErrorMessage] = "This is the only Admin, so their role can't be removed until another Admin is added."; - return View(viewModel); // return + return View(viewModel); } if (viewModel.IsAdmin != wasAdmin) { - var success = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin); - if (success) + adminStatusChanged = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin); + } + + if (propertiesChanged is true) + { + propertiesChanged = await _UserManager.UpdateAsync(user) is { Succeeded: true }; + } + + if (propertiesChanged.HasValue || adminStatusChanged.HasValue || approvalStatusChanged.HasValue) + { + if (propertiesChanged is not false && adminStatusChanged is not false && approvalStatusChanged is not false) { TempData[WellKnownTempData.SuccessMessage] = "User successfully updated"; } @@ -121,23 +140,22 @@ namespace BTCPayServer.Controllers } } - return RedirectToAction(nameof(User), new { userId = userId }); + return RedirectToAction(nameof(User), new { userId }); } - [Route("server/users/new")] - [HttpGet] + [HttpGet("server/users/new")] public IActionResult CreateUser() { + ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval; ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail; return View(); } - [Route("server/users/new")] - [HttpPost] + [HttpPost("server/users/new")] public async Task CreateUser(RegisterFromAdminViewModel model) { - var requiresConfirmedEmail = _policiesSettings.RequiresConfirmedEmail; - ViewData["AllowRequestEmailConfirmation"] = requiresConfirmedEmail; + ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval; + ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail; if (!_Options.CheatMode) model.IsAdmin = false; if (ModelState.IsValid) @@ -148,7 +166,9 @@ namespace BTCPayServer.Controllers UserName = model.Email, Email = model.Email, EmailConfirmed = model.EmailConfirmed, - RequiresEmailConfirmation = requiresConfirmedEmail, + RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail, + RequiresApproval = _policiesSettings.RequiresUserApproval, + Approved = model.Approved, Created = DateTimeOffset.UtcNow }; @@ -223,7 +243,6 @@ namespace BTCPayServer.Controllers { if (await _userService.IsUserTheOnlyOneAdmin(user)) { - // return return View("Confirm", new ConfirmModel("Delete admin", $"Unable to proceed: As the user {Html.Encode(user.Email)} is the last enabled admin, it cannot be removed.")); } @@ -281,6 +300,29 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(ListUsers)); } + [HttpGet("server/users/{userId}/approve")] + public async Task 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 {Html.Encode(user.Email)} will be {(approved ? "approved" : "unapproved")}. Are you sure?", (approved ? "Approve" : "Unapprove"))); + } + + [HttpPost("server/users/{userId}/approve")] + public async Task ApproveUserPost(string userId, bool approved) + { + var user = userId == null ? null : await _UserManager.FindByIdAsync(userId); + if (user == null) + return NotFound(); + + await _userService.SetUserApproval(userId, approved, Request.GetAbsoluteRootUri()); + + TempData[WellKnownTempData.SuccessMessage] = $"User {(approved ? "approved" : "unapproved")}"; + return RedirectToAction(nameof(ListUsers)); + } + [HttpGet("server/users/{userId}/verification-email")] public async Task SendVerificationEmail(string userId) { @@ -332,5 +374,8 @@ namespace BTCPayServer.Controllers [Display(Name = "Email confirmed?")] public bool EmailConfirmed { get; set; } + + [Display(Name = "User approved?")] + public bool Approved { get; set; } } } diff --git a/BTCPayServer/Events/UserApprovedEvent.cs b/BTCPayServer/Events/UserApprovedEvent.cs new file mode 100644 index 000000000..8b2989dcc --- /dev/null +++ b/BTCPayServer/Events/UserApprovedEvent.cs @@ -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; } + } +} diff --git a/BTCPayServer/Extensions/ActionLogicExtensions.cs b/BTCPayServer/Extensions/ActionLogicExtensions.cs index ba5e81421..8f146bc81 100644 --- a/BTCPayServer/Extensions/ActionLogicExtensions.cs +++ b/BTCPayServer/Extensions/ActionLogicExtensions.cs @@ -1,11 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; -using BTCPayServer.Configuration; using BTCPayServer.Logging; -using BTCPayServer.NTag424; using BTCPayServer.Services; using Microsoft.Extensions.Logging; diff --git a/BTCPayServer/Extensions/EmailSenderExtensions.cs b/BTCPayServer/Extensions/EmailSenderExtensions.cs index 51293801c..dff303297 100644 --- a/BTCPayServer/Extensions/EmailSenderExtensions.cs +++ b/BTCPayServer/Extensions/EmailSenderExtensions.cs @@ -23,6 +23,12 @@ namespace BTCPayServer.Services $"Please confirm your account by clicking this link: link"); } + public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link) + { + emailSender.SendEmail(address, "Your account has been approved", + $"Your account has been approved and you can now login here"); + } + public static void SendSetPasswordConfirmation(this IEmailSender emailSender, MailboxAddress address, string link, bool newPassword) { var subject = $"{(newPassword ? "Set" : "Update")} Password"; diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs index 4ebd335ae..318ac52f2 100644 --- a/BTCPayServer/Extensions/UrlHelperExtensions.cs +++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs @@ -29,6 +29,11 @@ namespace Microsoft.AspNetCore.Mvc new { userId, code }, scheme, host, pathbase); } + public static string LoginLink(this LinkGenerator urlHelper, string scheme, HostString host, string pathbase) + { + return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", null , scheme, host, pathbase); + } + public static string ResetPasswordCallbackLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase) { return urlHelper.GetUriByAction( diff --git a/BTCPayServer/HostedServices/UserEventHostedService.cs b/BTCPayServer/HostedServices/UserEventHostedService.cs index 0311d27f6..278f9c69b 100644 --- a/BTCPayServer/HostedServices/UserEventHostedService.cs +++ b/BTCPayServer/HostedServices/UserEventHostedService.cs @@ -6,6 +6,8 @@ using BTCPayServer.Events; using BTCPayServer.Logging; using BTCPayServer.Services; using BTCPayServer.Services.Mails; +using BTCPayServer.Services.Notifications; +using BTCPayServer.Services.Notifications.Blobs; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -19,20 +21,27 @@ namespace BTCPayServer.HostedServices { private readonly UserManager _userManager; private readonly EmailSenderFactory _emailSenderFactory; + private readonly NotificationSender _notificationSender; private readonly LinkGenerator _generator; - - public UserEventHostedService(EventAggregator eventAggregator, UserManager userManager, - EmailSenderFactory emailSenderFactory, LinkGenerator generator, Logs logs) : base(eventAggregator, logs) + public UserEventHostedService( + EventAggregator eventAggregator, + UserManager userManager, + EmailSenderFactory emailSenderFactory, + NotificationSender notificationSender, + LinkGenerator generator, + Logs logs) : base(eventAggregator, logs) { _userManager = userManager; _emailSenderFactory = emailSenderFactory; + _notificationSender = notificationSender; _generator = generator; } protected override void SubscribeToEvents() { Subscribe(); + Subscribe(); Subscribe(); } @@ -40,30 +49,39 @@ namespace BTCPayServer.HostedServices { string code; string callbackUrl; + Uri uri; + HostString host; + ApplicationUser user; MailboxAddress address; + IEmailSender emailSender; UserPasswordResetRequestedEvent userPasswordResetRequestedEvent; switch (evt) { case UserRegisteredEvent userRegisteredEvent: + user = userRegisteredEvent.User; Logs.PayServer.LogInformation( - $"A new user just registered {userRegisteredEvent.User.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}"); - if (!userRegisteredEvent.User.EmailConfirmed && userRegisteredEvent.User.RequiresEmailConfirmation) + $"A new user just registered {user.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}"); + if (user.RequiresApproval && !user.Approved) { - code = await _userManager.GenerateEmailConfirmationTokenAsync(userRegisteredEvent.User); - callbackUrl = _generator.EmailConfirmationLink(userRegisteredEvent.User.Id, code, - userRegisteredEvent.RequestUri.Scheme, - new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port), - userRegisteredEvent.RequestUri.PathAndQuery); + await _notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user)); + } + if (!user.EmailConfirmed && user.RequiresEmailConfirmation) + { + uri = userRegisteredEvent.RequestUri; + host = new HostString(uri.Host, uri.Port); + code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + callbackUrl = _generator.EmailConfirmationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery); userRegisteredEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl)); - address = userRegisteredEvent.User.GetMailboxAddress(); - (await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(address, callbackUrl); + address = user.GetMailboxAddress(); + emailSender = await _emailSenderFactory.GetEmailSender(); + emailSender.SendEmailConfirmation(address, callbackUrl); } else if (!await _userManager.HasPasswordAsync(userRegisteredEvent.User)) { - userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent() + userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent { CallbackUrlGenerated = userRegisteredEvent.CallbackUrlGenerated, - User = userRegisteredEvent.User, + User = user, RequestUri = userRegisteredEvent.RequestUri }; goto passwordSetter; @@ -72,22 +90,33 @@ namespace BTCPayServer.HostedServices { userRegisteredEvent.CallbackUrlGenerated?.SetResult(null); } - break; + + case UserApprovedEvent userApprovedEvent: + if (userApprovedEvent.Approved) + { + uri = userApprovedEvent.RequestUri; + host = new HostString(uri.Host, uri.Port); + address = userApprovedEvent.User.GetMailboxAddress(); + callbackUrl = _generator.LoginLink(uri.Scheme, host, uri.PathAndQuery); + emailSender = await _emailSenderFactory.GetEmailSender(); + emailSender.SendApprovalConfirmation(address, callbackUrl); + } + break; + case UserPasswordResetRequestedEvent userPasswordResetRequestedEvent2: userPasswordResetRequestedEvent = userPasswordResetRequestedEvent2; passwordSetter: - code = await _userManager.GeneratePasswordResetTokenAsync(userPasswordResetRequestedEvent.User); - var newPassword = await _userManager.HasPasswordAsync(userPasswordResetRequestedEvent.User); - callbackUrl = _generator.ResetPasswordCallbackLink(userPasswordResetRequestedEvent.User.Id, code, - userPasswordResetRequestedEvent.RequestUri.Scheme, - new HostString(userPasswordResetRequestedEvent.RequestUri.Host, - userPasswordResetRequestedEvent.RequestUri.Port), - userPasswordResetRequestedEvent.RequestUri.PathAndQuery); + uri = userPasswordResetRequestedEvent.RequestUri; + host = new HostString(uri.Host, uri.Port); + user = userPasswordResetRequestedEvent.User; + code = await _userManager.GeneratePasswordResetTokenAsync(user); + var newPassword = await _userManager.HasPasswordAsync(user); + callbackUrl = _generator.ResetPasswordCallbackLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery); userPasswordResetRequestedEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl)); - address = userPasswordResetRequestedEvent.User.GetMailboxAddress(); - (await _emailSenderFactory.GetEmailSender()) - .SendSetPasswordConfirmation(address, callbackUrl, newPassword); + address = user.GetMailboxAddress(); + emailSender = await _emailSenderFactory.GetEmailSender(); + emailSender.SendSetPasswordConfirmation(address, callbackUrl, newPassword); break; } } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 0b873711b..c30516ac1 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -436,8 +436,8 @@ namespace BTCPayServer.Hosting services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs b/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs index bc783bb22..d4d600028 100644 --- a/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs +++ b/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs @@ -11,7 +11,8 @@ namespace BTCPayServer.Models.ServerViewModels public string Id { get; set; } public string Name { get; set; } public string Email { get; set; } - public bool Verified { get; set; } + public bool? EmailConfirmed { get; set; } + public bool? Approved { get; set; } public bool Disabled { get; set; } public bool IsAdmin { get; set; } public DateTimeOffset? Created { get; set; } diff --git a/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs b/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs index 33edc298a..304a23448 100644 --- a/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs +++ b/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs @@ -7,6 +7,7 @@ using System.Text.Encodings.Web; using System.Threading.Tasks; using BTCPayServer.Client; using BTCPayServer.Data; +using BTCPayServer.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; @@ -58,14 +59,12 @@ namespace BTCPayServer.Security.Greenfield return AuthenticateResult.NoResult(); var key = await _apiKeyRepository.GetKey(apiKey, true); - - if (key == null || await _userManager.IsLockedOutAsync(key.User)) + if (!UserService.TryCanLogin(key?.User, out var error)) { - return AuthenticateResult.Fail("ApiKey authentication failed"); + return AuthenticateResult.Fail($"ApiKey authentication failed: {error}"); } - List claims = new List(); - claims.Add(new Claim(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId)); + var claims = new List { new (_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId) }; claims.AddRange((await _userManager.GetRolesAsync(key.User)).Select(s => new Claim(_identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, s))); claims.AddRange(Permission.ToPermissions(key.GetBlob()?.Permissions ?? Array.Empty()).Select(permission => new Claim(GreenfieldConstants.ClaimTypes.Permission, permission.ToString()))); diff --git a/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs b/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs index fd8b20645..7ce5d03b8 100644 --- a/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs +++ b/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs @@ -7,6 +7,7 @@ using System.Text.Encodings.Web; using System.Threading.Tasks; using BTCPayServer.Client; using BTCPayServer.Data; +using BTCPayServer.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -66,6 +67,10 @@ namespace BTCPayServer.Security.Greenfield .FirstOrDefaultAsync(applicationUser => applicationUser.NormalizedUserName == _userManager.NormalizeName(username)); + if (!UserService.TryCanLogin(user, out var error)) + { + return AuthenticateResult.Fail($"Basic authentication failed: {error}"); + } if (user.Fido2Credentials.Any()) { return AuthenticateResult.Fail("Cannot use Basic authentication with multi-factor is enabled."); diff --git a/BTCPayServer/Services/Notifications/Blobs/NewUserRequiresApprovalNotification.cs b/BTCPayServer/Services/Notifications/Blobs/NewUserRequiresApprovalNotification.cs new file mode 100644 index 000000000..cbedbea23 --- /dev/null +++ b/BTCPayServer/Services/Notifications/Blobs/NewUserRequiresApprovalNotification.cs @@ -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 + { + 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); + } + } +} diff --git a/BTCPayServer/Services/PoliciesSettings.cs b/BTCPayServer/Services/PoliciesSettings.cs index 3eb858d23..89926284d 100644 --- a/BTCPayServer/Services/PoliciesSettings.cs +++ b/BTCPayServer/Services/PoliciesSettings.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; -using BTCPayServer.Services.Apps; using BTCPayServer.Validation; using Newtonsoft.Json; @@ -14,6 +14,19 @@ namespace BTCPayServer.Services [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] [Display(Name = "Disable new user registration on the server")] public bool LockSubscription { get; set; } + + [JsonIgnore] + [Display(Name = "Enable new user registration on the server")] + public bool EnableRegistration + { + get => !LockSubscription; + set { LockSubscription = !value; } + } + + [DefaultValue(true)] + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] + [Display(Name = "Require new users to be approved by an admin after registration")] + public bool RequiresUserApproval { get; set; } = true; [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] [Display(Name = "Discourage search engines from indexing this site")] @@ -40,6 +53,14 @@ namespace BTCPayServer.Services [Display(Name = "Disable non-admins access to the user creation API endpoint")] public bool DisableNonAdminCreateUserApi { get; set; } + + [JsonIgnore] + [Display(Name = "Non-admins can access the user creation API endpoint")] + public bool EnableNonAdminCreateUserApi + { + get => !DisableNonAdminCreateUserApi; + set { DisableNonAdminCreateUserApi = !value; } + } public const string DefaultPluginSource = "https://plugin-builder.btcpayserver.org"; [UriAttribute] diff --git a/BTCPayServer/Services/UserService.cs b/BTCPayServer/Services/UserService.cs index 4d8efe862..c19a58aa8 100644 --- a/BTCPayServer/Services/UserService.cs +++ b/BTCPayServer/Services/UserService.cs @@ -1,10 +1,13 @@ #nullable enable using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; +using BTCPayServer; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.Services.Stores; using BTCPayServer.Storage.Services; using Microsoft.AspNetCore.Identity; @@ -20,6 +23,7 @@ namespace BTCPayServer.Services private readonly StoredFileRepository _storedFileRepository; private readonly FileService _fileService; private readonly StoreRepository _storeRepository; + private readonly EventAggregator _eventAggregator; private readonly ApplicationDbContextFactory _applicationDbContextFactory; private readonly ILogger _logger; @@ -27,6 +31,7 @@ namespace BTCPayServer.Services IServiceProvider serviceProvider, StoredFileRepository storedFileRepository, FileService fileService, + EventAggregator eventAggregator, StoreRepository storeRepository, ApplicationDbContextFactory applicationDbContextFactory, ILogger logger) @@ -34,6 +39,7 @@ namespace BTCPayServer.Services _serviceProvider = serviceProvider; _storedFileRepository = storedFileRepository; _fileService = fileService; + _eventAggregator = eventAggregator; _storeRepository = storeRepository; _applicationDbContextFactory = applicationDbContextFactory; _logger = logger; @@ -46,26 +52,89 @@ namespace BTCPayServer.Services (userRole, role) => role.Name).ToArray()))).ToListAsync(); } - public static ApplicationUserData FromModel(ApplicationUser data, string?[] roles) { - return new ApplicationUserData() + return new ApplicationUserData { Id = data.Id, Email = data.Email, EmailConfirmed = data.EmailConfirmed, RequiresEmailConfirmation = data.RequiresEmailConfirmation, + Approved = data.Approved, + RequiresApproval = data.RequiresApproval, Created = data.Created, Roles = roles, Disabled = data.LockoutEnabled && data.LockoutEnd is not null && DateTimeOffset.UtcNow < data.LockoutEnd.Value.UtcDateTime }; } - private bool IsDisabled(ApplicationUser user) + private static bool IsEmailConfirmed(ApplicationUser user) + { + return user.EmailConfirmed || !user.RequiresEmailConfirmation; + } + + private static bool IsApproved(ApplicationUser user) + { + return user.Approved || !user.RequiresApproval; + } + + private static bool IsDisabled(ApplicationUser user) { return user.LockoutEnabled && user.LockoutEnd is not null && DateTimeOffset.UtcNow < user.LockoutEnd.Value.UtcDateTime; } + + public static bool TryCanLogin([NotNullWhen(true)] ApplicationUser? user, [MaybeNullWhen(true)] out string error) + { + error = null; + if (user == null) + { + error = "Invalid login attempt."; + return false; + } + if (!IsEmailConfirmed(user)) + { + error = "You must have a confirmed email to log in."; + return false; + } + if (!IsApproved(user)) + { + error = "Your user account requires approval by an admin before you can log in."; + return false; + } + if (IsDisabled(user)) + { + error = "Your user account is currently disabled."; + return false; + } + return true; + } + + public async Task SetUserApproval(string userId, bool approved, Uri requestUri) + { + using var scope = _serviceProvider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + 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 ToggleUser(string userId, DateTimeOffset? lockedOutDeadline) { using var scope = _serviceProvider.CreateScope(); @@ -163,7 +232,6 @@ namespace BTCPayServer.Services } } - public async Task IsUserTheOnlyOneAdmin(ApplicationUser user) { using var scope = _serviceProvider.CreateScope(); @@ -175,7 +243,7 @@ namespace BTCPayServer.Services } var adminUsers = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin); var enabledAdminUsers = adminUsers - .Where(applicationUser => !IsDisabled(applicationUser)) + .Where(applicationUser => !IsDisabled(applicationUser) && IsApproved(applicationUser)) .Select(applicationUser => applicationUser.Id).ToList(); return enabledAdminUsers.Count == 1 && enabledAdminUsers.Contains(user.Id); diff --git a/BTCPayServer/Views/UIAccount/ForgotPassword.cshtml b/BTCPayServer/Views/UIAccount/ForgotPassword.cshtml index 2c472a217..35d44b98b 100644 --- a/BTCPayServer/Views/UIAccount/ForgotPassword.cshtml +++ b/BTCPayServer/Views/UIAccount/ForgotPassword.cshtml @@ -10,7 +10,7 @@

-
+
diff --git a/BTCPayServer/Views/UIAccount/Lockout.cshtml b/BTCPayServer/Views/UIAccount/Lockout.cshtml index 28584fe8f..da57f6f20 100644 --- a/BTCPayServer/Views/UIAccount/Lockout.cshtml +++ b/BTCPayServer/Views/UIAccount/Lockout.cshtml @@ -1,5 +1,4 @@ -@using BTCPayServer.Abstractions.Extensions -@model DateTimeOffset? +@model DateTimeOffset? @{ ViewData["Title"] = "Account disabled"; Layout = "_LayoutSignedOut"; diff --git a/BTCPayServer/Views/UIAccount/Login.cshtml b/BTCPayServer/Views/UIAccount/Login.cshtml index ec2365c50..66a639d4a 100644 --- a/BTCPayServer/Views/UIAccount/Login.cshtml +++ b/BTCPayServer/Views/UIAccount/Login.cshtml @@ -9,16 +9,16 @@
-
+
- +
diff --git a/BTCPayServer/Views/UIAccount/Register.cshtml b/BTCPayServer/Views/UIAccount/Register.cshtml index 0ef260d9c..743f7d5a7 100644 --- a/BTCPayServer/Views/UIAccount/Register.cshtml +++ b/BTCPayServer/Views/UIAccount/Register.cshtml @@ -8,7 +8,7 @@
-
+
diff --git a/BTCPayServer/Views/UIAccount/SecondaryLogin.cshtml b/BTCPayServer/Views/UIAccount/SecondaryLogin.cshtml index 8e53909f6..1c8d78832 100644 --- a/BTCPayServer/Views/UIAccount/SecondaryLogin.cshtml +++ b/BTCPayServer/Views/UIAccount/SecondaryLogin.cshtml @@ -13,9 +13,9 @@ } -@if (Model.LoginWith2FaViewModel != null && Model.LoginWithFido2ViewModel != null&& Model.LoginWithLNURLAuthViewModel != null) +@if (Model.LoginWith2FaViewModel != null && Model.LoginWithFido2ViewModel != null && Model.LoginWithLNURLAuthViewModel != null) { -
+
} else if (Model.LoginWith2FaViewModel == null && Model.LoginWithFido2ViewModel == null && Model.LoginWithLNURLAuthViewModel == null) { diff --git a/BTCPayServer/Views/UIAccount/SetPassword.cshtml b/BTCPayServer/Views/UIAccount/SetPassword.cshtml index 10c5c4f3d..1e91f2d03 100644 --- a/BTCPayServer/Views/UIAccount/SetPassword.cshtml +++ b/BTCPayServer/Views/UIAccount/SetPassword.cshtml @@ -5,7 +5,7 @@ } -
+
@if (Model.EmailSetInternally) diff --git a/BTCPayServer/Views/UIPullPayment/ViewPullPayment.cshtml b/BTCPayServer/Views/UIPullPayment/ViewPullPayment.cshtml index fc8e77a0b..9995e8378 100644 --- a/BTCPayServer/Views/UIPullPayment/ViewPullPayment.cshtml +++ b/BTCPayServer/Views/UIPullPayment/ViewPullPayment.cshtml @@ -30,11 +30,6 @@ -
diff --git a/BTCPayServer/Views/UIServer/CreateUser.cshtml b/BTCPayServer/Views/UIServer/CreateUser.cshtml index cc222dde3..b6a8e1d30 100644 --- a/BTCPayServer/Views/UIServer/CreateUser.cshtml +++ b/BTCPayServer/Views/UIServer/CreateUser.cshtml @@ -36,8 +36,6 @@
} - - @if (ViewData["AllowRequestEmailConfirmation"] is true) {
@@ -46,8 +44,16 @@
} + @if (ViewData["AllowRequestApproval"] is true) + { +
+ + + +
+ } - +
diff --git a/BTCPayServer/Views/UIServer/ListUsers.cshtml b/BTCPayServer/Views/UIServer/ListUsers.cshtml index 5e1ed70a4..dc696d517 100644 --- a/BTCPayServer/Views/UIServer/ListUsers.cshtml +++ b/BTCPayServer/Views/UIServer/ListUsers.cshtml @@ -3,16 +3,12 @@ @{ ViewData.SetActivePage(ServerNavPages.Users); var nextUserEmailSortOrder = (string)ViewData["NextUserEmailSortOrder"]; - String userEmailSortOrder = null; - switch (nextUserEmailSortOrder) + var userEmailSortOrder = nextUserEmailSortOrder switch { - case "asc": - userEmailSortOrder = "desc"; - break; - case "desc": - userEmailSortOrder = "asc"; - break; - } + "asc" => "desc", + "desc" => "asc", + _ => null + }; var sortIconClass = "fa-sort"; if (userEmailSortOrder != null) @@ -20,8 +16,8 @@ sortIconClass = $"fa-sort-alpha-{userEmailSortOrder}"; } - var sortByDesc = "Sort by descending..."; - var sortByAsc = "Sort by ascending..."; + const string sortByDesc = "Sort by descending..."; + const string sortByAsc = "Sort by ascending..."; }
@@ -31,14 +27,8 @@
-
-
- - -
- + +
@@ -53,58 +43,52 @@ title="@(nextUserEmailSortOrder == "desc" ? sortByAsc : sortByDesc)" > Email - + - Created - Verified - Enabled - Actions + Created + Status + - + @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") + }; - - @user.Email + + @user.Email @foreach (var role in user.Roles) { @Model.Roles[role] } @user.Created?.ToBrowserDate() - - @if (user.Verified) - { - - } - else - { - - } + + @status.Item1 - - @if (!user.Disabled) - { - - } - else - { - - } - - - @if (!user.Verified && !user.Disabled) { - Resend verification email - - - } - Edit - Remove - - - @(user.Disabled ? "Enable" : "Disable") - + +
+ @if (user is { EmailConfirmed: false, Disabled: false }) { + Resend email + } + else if (user is { Approved: false, Disabled: false }) + { + Approve + } + else + { + @(user.Disabled ? "Enable" : "Disable") + } + Edit + Remove +
} diff --git a/BTCPayServer/Views/UIServer/Policies.cshtml b/BTCPayServer/Views/UIServer/Policies.cshtml index f5489aa80..afaea87a4 100644 --- a/BTCPayServer/Views/UIServer/Policies.cshtml +++ b/BTCPayServer/Views/UIServer/Policies.cshtml @@ -11,15 +11,10 @@ @section PageHeadContent { } @@ -31,10 +26,59 @@ }
-
+
-

Existing User Settings

+

Registration Settings

+
+ + + + +
+
+ @{ + var emailSettings = (await _SettingsRepository.GetSettingAsync()) ?? 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; + } + + + + + + + @if (!isEmailConfigured) + { +
+ Your email server has not been configured. Please configure it first. +
+ } +
+
+ + + +
+
+ + + + +
+
+
+
+ +
+

User Settings

@@ -70,49 +114,6 @@
-
-

New User Settings

-
- @{ - var emailSettings = (await _SettingsRepository.GetSettingAsync()) ?? 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; - } - - - - - - - @if (!isEmailConfigured) - { -
- Your email server has not been configured. Please configure it first. -
- } -
-
- - - - -
- -
- - - - -
-
-

Email Settings

@@ -181,7 +182,7 @@

Customization Settings

- + @if (!Model.DomainToAppMapping.Any()) { @@ -214,7 +215,7 @@
diff --git a/BTCPayServer/Views/UIServer/User.cshtml b/BTCPayServer/Views/UIServer/User.cshtml index 7e8c778d1..633958211 100644 --- a/BTCPayServer/Views/UIServer/User.cshtml +++ b/BTCPayServer/Views/UIServer/User.cshtml @@ -5,14 +5,26 @@

@ViewData["Title"]

-
-
- -
- - -
- - +
+
+ +
-
+ @if (Model.Approved.HasValue) + { +
+ + +
+ + } + @if (Model.EmailConfirmed.HasValue) + { +
+ + +
+ + } + + diff --git a/BTCPayServer/wwwroot/main/site.css b/BTCPayServer/wwwroot/main/site.css index 2c7826586..640392786 100644 --- a/BTCPayServer/wwwroot/main/site.css +++ b/BTCPayServer/wwwroot/main/site.css @@ -42,6 +42,11 @@ margin-bottom: 0; } +.no-marker > ul { + list-style-type: none; + padding-left: 0; +} + /* General and site-wide Bootstrap modifications */ p { margin-bottom: 1.5rem; diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json index 1584f90a7..0545c4ea9 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json @@ -255,7 +255,7 @@ "tags": [ "Users" ], - "summary": "Toggle user", + "summary": "Toggle user lock out", "description": "Lock or unlock a user.\n\nMust be an admin to perform this operation.\n\nAttempting to lock the only admin user will not succeed.", "parameters": [ { @@ -283,10 +283,63 @@ "description": "User has been successfully toggled" }, "401": { - "description": "Missing authorization for deleting the user" + "description": "Missing authorization for locking the user" }, "403": { - "description": "Authorized but forbidden to disable the user. Can happen if you attempt to disable the only admin user." + "description": "Authorized but forbidden to lock the user. Can happen if you attempt to disable the only admin user." + }, + "404": { + "description": "User with provided ID was not found" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.user.canmodifyserversettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/users/{idOrEmail}/approve": { + "post": { + "operationId": "Users_ToggleUserApproval", + "tags": [ + "Users" + ], + "summary": "Toggle user approval", + "description": "Approve or unapprove a user.\n\nMust be an admin to perform this operation.\n\nAttempting to (un)approve a user for which this requirement does not exist will not succeed.", + "parameters": [ + { + "name": "idOrEmail", + "in": "path", + "required": true, + "description": "The ID of the user to be un/approved", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApproveUserRequest" + } + } + } + }, + "responses": { + "200": { + "description": "User has been successfully toggled" + }, + "401": { + "description": "Missing authorization for approving the user" + }, + "403": { + "description": "Authorized but forbidden to approve the user. Can happen if you attempt to set the status of a user that does not have the approval requirement." }, "404": { "description": "User with provided ID was not found" @@ -325,7 +378,15 @@ }, "requiresEmailConfirmation": { "type": "boolean", - "description": "True if the email requires email confirmation to log in" + "description": "True if the email requires confirmation to log in" + }, + "approved": { + "type": "boolean", + "description": "True if an admin has approved the user" + }, + "requiresApproval": { + "type": "boolean", + "description": "True if the instance requires approval to log in" }, "created": { "nullable": true, @@ -355,6 +416,16 @@ "description": "Whether to lock or unlock the user" } } + }, + "ApproveUserRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "approved": { + "type": "boolean", + "description": "Whether to approve or unapprove the user" + } + } } } },