mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-21 22:11:48 +01:00
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:
parent
411e0334d0
commit
6290b0f3bf
40 changed files with 1010 additions and 353 deletions
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
6
BTCPayServer.Client/Models/ApproveUserRequest.cs
Normal file
6
BTCPayServer.Client/Models/ApproveUserRequest.cs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
namespace BTCPayServer.Client;
|
||||||
|
|
||||||
|
public class ApproveUserRequest
|
||||||
|
{
|
||||||
|
public bool Approved { get; set; }
|
||||||
|
}
|
|
@ -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; }
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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")]
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
12
BTCPayServer/Events/UserApprovedEvent.cs
Normal file
12
BTCPayServer/Events/UserApprovedEvent.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>();
|
||||||
|
|
|
@ -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; }
|
||||||
|
|
|
@ -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())));
|
||||||
|
|
|
@ -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.");
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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]
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
@using BTCPayServer.Abstractions.Extensions
|
@model DateTimeOffset?
|
||||||
@model DateTimeOffset?
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Account disabled";
|
ViewData["Title"] = "Account disabled";
|
||||||
Layout = "_LayoutSignedOut";
|
Layout = "_LayoutSignedOut";
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Reference in a new issue