mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +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;
|
||||
}
|
||||
|
||||
public virtual async Task<bool> ApproveUser(string idOrEmail, bool approved, CancellationToken token = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/{idOrEmail}/approve", null,
|
||||
new ApproveUserRequest { Approved = approved }, HttpMethod.Post), token);
|
||||
await HandleResponse(response);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public virtual async Task<ApplicationUserData[]> GetUsers(CancellationToken token = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token);
|
||||
|
|
|
@ -25,6 +25,16 @@ namespace BTCPayServer.Client.Models
|
|||
/// </summary>
|
||||
public bool RequiresEmailConfirmation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the user was approved by an admin
|
||||
/// </summary>
|
||||
public bool Approved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// whether the user needed approval on account creation
|
||||
/// </summary>
|
||||
public bool RequiresApproval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// the roles of the user
|
||||
/// </summary>
|
||||
|
|
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 bool RequiresEmailConfirmation { get; set; }
|
||||
public bool RequiresApproval { get; set; }
|
||||
public bool Approved { get; set; }
|
||||
public List<StoredFile> StoredFiles { get; set; }
|
||||
[Obsolete("U2F support has been replace with FIDO2")]
|
||||
public List<U2FDevice> U2FDevices { get; set; }
|
||||
|
|
|
@ -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")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Approved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte[]>("Blob")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
|
@ -158,6 +161,9 @@ namespace BTCPayServer.Migrations
|
|||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("RequiresApproval")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("RequiresEmailConfirmation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
|
|
@ -245,6 +245,9 @@ namespace BTCPayServer.Tests
|
|||
rateProvider.Providers.Add("kraken", kraken);
|
||||
}
|
||||
|
||||
// reset test server policies
|
||||
var settings = GetService<SettingsRepository>();
|
||||
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = false });
|
||||
|
||||
TestLogs.LogInformation("Waiting site is operational...");
|
||||
await WaitSiteIsOperational();
|
||||
|
|
|
@ -694,14 +694,10 @@ namespace BTCPayServer.Tests
|
|||
// Try loading 1 user by email. Loading myself.
|
||||
await AssertHttpError(403, async () => await badClient.GetUserByIdOrEmail(badUser.Email));
|
||||
|
||||
|
||||
|
||||
|
||||
// Why is this line needed? I saw it in "CanDeleteUsersViaApi" as well. Is this part of the cleanup?
|
||||
tester.Stores.Remove(adminUser.StoreId);
|
||||
}
|
||||
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanCreateUsersViaAPI()
|
||||
|
@ -3571,6 +3567,78 @@ namespace BTCPayServer.Tests
|
|||
await newUserBasicClient.GetCurrentUser();
|
||||
}
|
||||
|
||||
[Fact(Timeout = 60 * 2 * 1000)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task ApproveUserTests()
|
||||
{
|
||||
using var tester = CreateServerTester();
|
||||
await tester.StartAsync();
|
||||
var admin = tester.NewAccount();
|
||||
await admin.GrantAccessAsync(true);
|
||||
var adminClient = await admin.CreateClient(Policies.Unrestricted);
|
||||
Assert.False((await adminClient.GetUserByIdOrEmail(admin.UserId)).RequiresApproval);
|
||||
Assert.Empty(await adminClient.GetNotifications());
|
||||
|
||||
// require approval
|
||||
var settings = tester.PayTester.GetService<SettingsRepository>();
|
||||
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = true });
|
||||
|
||||
// new user needs approval
|
||||
var unapprovedUser = tester.NewAccount();
|
||||
await unapprovedUser.GrantAccessAsync();
|
||||
var unapprovedUserBasicAuthClient = await unapprovedUser.CreateClient();
|
||||
await AssertAPIError("unauthenticated", async () =>
|
||||
{
|
||||
await unapprovedUserBasicAuthClient.GetCurrentUser();
|
||||
});
|
||||
var unapprovedUserApiKeyClient = await unapprovedUser.CreateClient(Policies.Unrestricted);
|
||||
await AssertAPIError("unauthenticated", async () =>
|
||||
{
|
||||
await unapprovedUserApiKeyClient.GetCurrentUser();
|
||||
});
|
||||
Assert.True((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).RequiresApproval);
|
||||
Assert.False((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
|
||||
Assert.Single(await adminClient.GetNotifications(false));
|
||||
|
||||
// approve
|
||||
Assert.True(await adminClient.ApproveUser(unapprovedUser.UserId, true, CancellationToken.None));
|
||||
Assert.True((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
|
||||
Assert.True((await unapprovedUserApiKeyClient.GetCurrentUser()).Approved);
|
||||
Assert.True((await unapprovedUserBasicAuthClient.GetCurrentUser()).Approved);
|
||||
|
||||
// un-approve
|
||||
Assert.True(await adminClient.ApproveUser(unapprovedUser.UserId, false, CancellationToken.None));
|
||||
Assert.False((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
|
||||
await AssertAPIError("unauthenticated", async () =>
|
||||
{
|
||||
await unapprovedUserApiKeyClient.GetCurrentUser();
|
||||
});
|
||||
await AssertAPIError("unauthenticated", async () =>
|
||||
{
|
||||
await unapprovedUserBasicAuthClient.GetCurrentUser();
|
||||
});
|
||||
|
||||
// reset policies to not require approval
|
||||
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = false });
|
||||
|
||||
// new user does not need approval
|
||||
var newUser = tester.NewAccount();
|
||||
await newUser.GrantAccessAsync();
|
||||
var newUserBasicAuthClient = await newUser.CreateClient();
|
||||
var newUserApiKeyClient = await newUser.CreateClient(Policies.Unrestricted);
|
||||
Assert.False((await newUserApiKeyClient.GetCurrentUser()).RequiresApproval);
|
||||
Assert.False((await newUserApiKeyClient.GetCurrentUser()).Approved);
|
||||
Assert.False((await newUserBasicAuthClient.GetCurrentUser()).RequiresApproval);
|
||||
Assert.False((await newUserBasicAuthClient.GetCurrentUser()).Approved);
|
||||
Assert.Single(await adminClient.GetNotifications(false));
|
||||
|
||||
// try unapproving user which does not have the RequiresApproval flag
|
||||
await AssertAPIError("invalid-state", async () =>
|
||||
{
|
||||
await adminClient.ApproveUser(newUser.UserId, false, CancellationToken.None);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(Timeout = 60 * 2 * 1000)]
|
||||
[Trait("Integration", "Integration")]
|
||||
[Trait("Lightning", "Lightning")]
|
||||
|
|
|
@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Models;
|
|||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Lightning.CLightning;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Views.Manage;
|
||||
using BTCPayServer.Views.Server;
|
||||
using BTCPayServer.Views.Stores;
|
||||
|
@ -76,6 +77,7 @@ namespace BTCPayServer.Tests
|
|||
// A bit less than test timeout
|
||||
TimeSpan.FromSeconds(50));
|
||||
}
|
||||
|
||||
ServerUri = Server.PayTester.ServerUri;
|
||||
Driver.Manage().Window.Maximize();
|
||||
|
||||
|
|
|
@ -405,6 +405,148 @@ namespace BTCPayServer.Tests
|
|||
Assert.Contains("/login", s.Driver.Url);
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
public async Task CanRequireApprovalForNewAccounts()
|
||||
{
|
||||
using var s = CreateSeleniumTester();
|
||||
await s.StartAsync();
|
||||
|
||||
var settings = s.Server.PayTester.GetService<SettingsRepository>();
|
||||
var policies = await settings.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
|
||||
Assert.True(policies.EnableRegistration);
|
||||
Assert.False(policies.RequiresUserApproval);
|
||||
|
||||
// Register admin and adapt policies
|
||||
s.RegisterNewUser(true);
|
||||
var admin = s.AsTestAccount();
|
||||
s.GoToHome();
|
||||
s.GoToServer(ServerNavPages.Policies);
|
||||
Assert.True(s.Driver.FindElement(By.Id("EnableRegistration")).Selected);
|
||||
Assert.False(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
|
||||
s.Driver.FindElement(By.Id("RequiresUserApproval")).Click();
|
||||
s.Driver.FindElement(By.Id("SaveButton")).Click();
|
||||
Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text);
|
||||
Assert.True(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
|
||||
|
||||
// Check user create view has approval checkbox
|
||||
s.GoToServer(ServerNavPages.Users);
|
||||
s.Driver.FindElement(By.Id("CreateUser")).Click();
|
||||
Assert.False(s.Driver.FindElement(By.Id("Approved")).Selected);
|
||||
|
||||
// Ensure there is no unread notification yet
|
||||
s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge"));
|
||||
s.Logout();
|
||||
|
||||
// Register user and try to log in
|
||||
s.GoToRegister();
|
||||
s.RegisterNewUser();
|
||||
s.Driver.AssertNoError();
|
||||
Assert.Contains("Account created. The new account requires approval by an admin before you can log in", s.FindAlertMessage().Text);
|
||||
Assert.Contains("/login", s.Driver.Url);
|
||||
|
||||
var unapproved = s.AsTestAccount();
|
||||
s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password);
|
||||
Assert.Contains("Your user account requires approval by an admin before you can log in", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning).Text);
|
||||
Assert.Contains("/login", s.Driver.Url);
|
||||
|
||||
// Login with admin
|
||||
s.GoToLogin();
|
||||
s.LogIn(admin.RegisterDetails.Email, admin.RegisterDetails.Password);
|
||||
s.GoToHome();
|
||||
|
||||
// Check notification
|
||||
TestUtils.Eventually(() => Assert.Equal("1", s.Driver.FindElement(By.Id("NotificationsBadge")).Text));
|
||||
s.Driver.FindElement(By.Id("NotificationsHandle")).Click();
|
||||
Assert.Matches($"New user {unapproved.RegisterDetails.Email} requires approval", s.Driver.FindElement(By.CssSelector("#NotificationsList .notification")).Text);
|
||||
s.Driver.FindElement(By.Id("NotificationsMarkAllAsSeen")).Click();
|
||||
|
||||
// Reset approval policy
|
||||
s.GoToServer(ServerNavPages.Policies);
|
||||
Assert.True(s.Driver.FindElement(By.Id("EnableRegistration")).Selected);
|
||||
Assert.True(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
|
||||
s.Driver.FindElement(By.Id("RequiresUserApproval")).Click();
|
||||
s.Driver.FindElement(By.Id("SaveButton")).Click();
|
||||
Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text);
|
||||
Assert.False(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
|
||||
|
||||
// Check user create view does not have approval checkbox
|
||||
s.GoToServer(ServerNavPages.Users);
|
||||
s.Driver.FindElement(By.Id("CreateUser")).Click();
|
||||
s.Driver.ElementDoesNotExist(By.Id("Approved"));
|
||||
|
||||
s.Logout();
|
||||
|
||||
// Still requires approval for user who registered before
|
||||
s.GoToLogin();
|
||||
s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password);
|
||||
Assert.Contains("Your user account requires approval by an admin before you can log in", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning).Text);
|
||||
Assert.Contains("/login", s.Driver.Url);
|
||||
|
||||
// New user can register and gets in without approval
|
||||
s.GoToRegister();
|
||||
s.RegisterNewUser();
|
||||
s.Driver.AssertNoError();
|
||||
Assert.DoesNotContain("/login", s.Driver.Url);
|
||||
var autoApproved = s.AsTestAccount();
|
||||
s.CreateNewStore();
|
||||
s.Logout();
|
||||
|
||||
// Login with admin and check list
|
||||
s.GoToLogin();
|
||||
s.LogIn(admin.RegisterDetails.Email, admin.RegisterDetails.Password);
|
||||
s.GoToHome();
|
||||
|
||||
// No notification this time
|
||||
s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge"));
|
||||
|
||||
// Check users list
|
||||
s.GoToServer(ServerNavPages.Users);
|
||||
var rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
|
||||
Assert.True(rows.Count >= 3);
|
||||
|
||||
// Check user which didn't require approval
|
||||
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
|
||||
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(autoApproved.RegisterDetails.Email);
|
||||
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
|
||||
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
|
||||
Assert.Single(rows);
|
||||
Assert.Contains(autoApproved.RegisterDetails.Email, rows.First().Text);
|
||||
s.Driver.ElementDoesNotExist(By.CssSelector("#UsersList tr:first-child .user-approved"));
|
||||
// Edit view does not contain approve toggle
|
||||
s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-edit")).Click();
|
||||
s.Driver.ElementDoesNotExist(By.Id("Approved"));
|
||||
|
||||
// Check user which still requires approval
|
||||
s.GoToServer(ServerNavPages.Users);
|
||||
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
|
||||
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(unapproved.RegisterDetails.Email);
|
||||
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
|
||||
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
|
||||
Assert.Single(rows);
|
||||
Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text);
|
||||
Assert.Contains("Pending Approval", s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-status")).Text);
|
||||
// Approve user
|
||||
s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-edit")).Click();
|
||||
s.Driver.FindElement(By.Id("Approved")).Click();
|
||||
s.Driver.FindElement(By.Id("SaveUser")).Click();
|
||||
Assert.Contains("User successfully updated", s.FindAlertMessage().Text);
|
||||
// Check list again
|
||||
s.GoToServer(ServerNavPages.Users);
|
||||
Assert.Contains(unapproved.RegisterDetails.Email, s.Driver.FindElement(By.Id("SearchTerm")).GetAttribute("value"));
|
||||
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
|
||||
Assert.Single(rows);
|
||||
Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text);
|
||||
Assert.Contains("Active", s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-status")).Text);
|
||||
|
||||
// Finally, login user that needed approval
|
||||
s.Logout();
|
||||
s.GoToLogin();
|
||||
s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password);
|
||||
s.Driver.AssertNoError();
|
||||
Assert.DoesNotContain("/login", s.Driver.Url);
|
||||
s.CreateNewStore();
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
public async Task CanUseSSHService()
|
||||
{
|
||||
|
|
|
@ -2325,17 +2325,21 @@ namespace BTCPayServer.Tests
|
|||
using var tester = CreateServerTester(newDb: true);
|
||||
await tester.StartAsync();
|
||||
var f = tester.PayTester.GetService<ApplicationDbContextFactory>();
|
||||
const string id = "BTCPayServer.Services.PoliciesSettings";
|
||||
using (var ctx = f.CreateContext())
|
||||
{
|
||||
var setting = new SettingData() { Id = "BTCPayServer.Services.PoliciesSettings" };
|
||||
setting.Value = JObject.Parse("{\"RootAppId\": null, \"RootAppType\": 1, \"Experimental\": false, \"PluginSource\": null, \"LockSubscription\": false, \"DisableSSHService\": false, \"PluginPreReleases\": false, \"BlockExplorerLinks\": [],\"DomainToAppMapping\": [{\"AppId\": \"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\", \"Domain\": \"donate.nicolas-dorier.com\", \"AppType\": 0}], \"CheckForNewVersions\": false, \"AllowHotWalletForAll\": false, \"RequiresConfirmedEmail\": false, \"DiscourageSearchEngines\": false, \"DisableInstantNotifications\": false, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString();
|
||||
// remove existing policies setting
|
||||
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == id);
|
||||
if (setting != null) ctx.Settings.Remove(setting);
|
||||
// create legacy policies setting that needs migration
|
||||
setting = new SettingData { Id = id, Value = JObject.Parse("{\"RootAppId\": null, \"RootAppType\": 1, \"Experimental\": false, \"PluginSource\": null, \"LockSubscription\": false, \"DisableSSHService\": false, \"PluginPreReleases\": false, \"BlockExplorerLinks\": [],\"DomainToAppMapping\": [{\"AppId\": \"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\", \"Domain\": \"donate.nicolas-dorier.com\", \"AppType\": 0}], \"CheckForNewVersions\": false, \"AllowHotWalletForAll\": false, \"RequiresConfirmedEmail\": false, \"DiscourageSearchEngines\": false, \"DisableInstantNotifications\": false, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString() };
|
||||
ctx.Settings.Add(setting);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
await RestartMigration(tester);
|
||||
using (var ctx = f.CreateContext())
|
||||
{
|
||||
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == "BTCPayServer.Services.PoliciesSettings");
|
||||
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == id);
|
||||
var o = JObject.Parse(setting.Value);
|
||||
Assert.Equal("Crowdfund", o["RootAppType"].Value<string>());
|
||||
o = (JObject)((JArray)o["DomainToAppMapping"])[0];
|
||||
|
|
|
@ -92,6 +92,26 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
$"{(request.Locked ? "Locking" : "Unlocking")} user failed");
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpPost("~/api/v1/users/{idOrEmail}/approve")]
|
||||
public async Task<IActionResult> ApproveUser(string idOrEmail, ApproveUserRequest request)
|
||||
{
|
||||
var user = await _userManager.FindByIdOrEmail(idOrEmail);
|
||||
if (user is null)
|
||||
{
|
||||
return this.UserNotFound();
|
||||
}
|
||||
|
||||
var success = false;
|
||||
if (user.RequiresApproval)
|
||||
{
|
||||
success = await _userService.SetUserApproval(user.Id, request.Approved, Request.GetAbsoluteRootUri());
|
||||
}
|
||||
|
||||
return success ? Ok() : this.CreateAPIError("invalid-state",
|
||||
$"{(request.Approved ? "Approving" : "Unapproving")} user failed");
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpGet("~/api/v1/users/")]
|
||||
public async Task<ActionResult<ApplicationUserData[]>> GetUsers()
|
||||
|
@ -163,7 +183,9 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
UserName = request.Email,
|
||||
Email = request.Email,
|
||||
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
|
||||
RequiresApproval = policies.RequiresUserApproval,
|
||||
Created = DateTimeOffset.UtcNow,
|
||||
Approved = !anyAdmin && isAdmin // auto-approve first admin
|
||||
};
|
||||
var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password);
|
||||
if (!passwordValidation.Succeeded)
|
||||
|
|
|
@ -1084,6 +1084,13 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
new LockUserRequest { Locked = disabled }));
|
||||
}
|
||||
|
||||
public override async Task<bool> ApproveUser(string idOrEmail, bool approved, CancellationToken token = default)
|
||||
{
|
||||
return GetFromActionResult<bool>(
|
||||
await GetController<GreenfieldUsersController>().ApproveUser(idOrEmail,
|
||||
new ApproveUserRequest { Approved = approved }));
|
||||
}
|
||||
|
||||
public override async Task<OnChainWalletTransactionData> PatchOnChainWalletTransaction(string storeId,
|
||||
string cryptoCode, string transactionId,
|
||||
PatchOnChainTransactionRequest request, bool force = false, CancellationToken token = default)
|
||||
|
|
|
@ -2,7 +2,6 @@ using System;
|
|||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
|
@ -109,6 +108,7 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl))
|
||||
return RedirectToLocal();
|
||||
|
||||
// Clear the existing external cookie to ensure a clean login process
|
||||
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
|
||||
|
||||
|
@ -118,15 +118,13 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
|
||||
ViewData["ReturnUrl"] = returnUrl;
|
||||
return View(nameof(Login), new LoginViewModel() { Email = email });
|
||||
return View(nameof(Login), new LoginViewModel { Email = email });
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("/login/code")]
|
||||
[AllowAnonymous]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
|
||||
|
||||
public async Task<IActionResult> LoginWithCode(string loginCode, string returnUrl = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(loginCode))
|
||||
|
@ -134,17 +132,26 @@ namespace BTCPayServer.Controllers
|
|||
var userId = _userLoginCodeService.Verify(loginCode);
|
||||
if (userId is null)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty,
|
||||
"Login code was invalid");
|
||||
return await Login(returnUrl, null);
|
||||
TempData[WellKnownTempData.ErrorMessage] = "Login code was invalid";
|
||||
return await Login(returnUrl);
|
||||
}
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
|
||||
_logger.LogInformation("User with ID {UserId} logged in with a login code.", user.Id);
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
if (!UserService.TryCanLogin(user, out var message))
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
|
||||
Message = message
|
||||
});
|
||||
return await Login(returnUrl);
|
||||
}
|
||||
|
||||
_logger.LogInformation("User with ID {UserId} logged in with a login code", user!.Id);
|
||||
await _signInManager.SignInAsync(user, false, "LoginCode");
|
||||
return RedirectToLocal(returnUrl);
|
||||
}
|
||||
return await Login(returnUrl, null);
|
||||
return await Login(returnUrl);
|
||||
}
|
||||
|
||||
[HttpPost("/login")]
|
||||
|
@ -161,24 +168,20 @@ namespace BTCPayServer.Controllers
|
|||
ViewData["ReturnUrl"] = returnUrl;
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
// Require the user to have a confirmed email before they can log on.
|
||||
// Require the user to pass basic checks (approval, confirmed email, not disabled) before they can log on
|
||||
var user = await _userManager.FindByEmailAsync(model.Email);
|
||||
if (user != null)
|
||||
const string errorMessage = "Invalid login attempt.";
|
||||
if (!UserService.TryCanLogin(user, out var message))
|
||||
{
|
||||
if (user.RequiresEmailConfirmation && !await _userManager.IsEmailConfirmedAsync(user))
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
ModelState.AddModelError(string.Empty,
|
||||
"You must have a confirmed email to log in.");
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
|
||||
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
|
||||
Message = message
|
||||
});
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var fido2Devices = await _fido2Service.HasCredentials(user.Id);
|
||||
var fido2Devices = await _fido2Service.HasCredentials(user!.Id);
|
||||
var lnurlAuthCredentials = await _lnurlAuthService.HasCredentials(user.Id);
|
||||
if (!await _userManager.IsLockedOutAsync(user) && (fido2Devices || lnurlAuthCredentials))
|
||||
{
|
||||
|
@ -196,33 +199,30 @@ namespace BTCPayServer.Controllers
|
|||
};
|
||||
}
|
||||
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel()
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel
|
||||
{
|
||||
LoginWith2FaViewModel = twoFModel,
|
||||
LoginWithFido2ViewModel = fido2Devices ? await BuildFido2ViewModel(model.RememberMe, user) : null,
|
||||
LoginWithLNURLAuthViewModel = lnurlAuthCredentials ? await BuildLNURLAuthViewModel(model.RememberMe, user) : null,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await _userManager.AccessFailedAsync(user);
|
||||
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
|
||||
return View(model);
|
||||
|
||||
}
|
||||
await _userManager.AccessFailedAsync(user);
|
||||
ModelState.AddModelError(string.Empty, errorMessage!);
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
_logger.LogInformation($"User '{user.Id}' logged in.");
|
||||
_logger.LogInformation("User {UserId} logged in", user.Id);
|
||||
return RedirectToLocal(returnUrl);
|
||||
}
|
||||
if (result.RequiresTwoFactor)
|
||||
{
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel()
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel
|
||||
{
|
||||
LoginWith2FaViewModel = new LoginWith2faViewModel()
|
||||
LoginWith2FaViewModel = new LoginWith2faViewModel
|
||||
{
|
||||
RememberMe = model.RememberMe
|
||||
}
|
||||
|
@ -230,14 +230,12 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
if (result.IsLockedOut)
|
||||
{
|
||||
_logger.LogWarning($"User '{user.Id}' account locked out.");
|
||||
_logger.LogWarning("User {UserId} account locked out", user.Id);
|
||||
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
|
||||
}
|
||||
else
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
ModelState.AddModelError(string.Empty, errorMessage);
|
||||
return View(model);
|
||||
}
|
||||
|
||||
// If we got this far, something failed, redisplay form
|
||||
|
@ -253,7 +251,7 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
return null;
|
||||
}
|
||||
return new LoginWithFido2ViewModel()
|
||||
return new LoginWithFido2ViewModel
|
||||
{
|
||||
Data = r,
|
||||
UserId = user.Id,
|
||||
|
@ -263,7 +261,6 @@ namespace BTCPayServer.Controllers
|
|||
return null;
|
||||
}
|
||||
|
||||
|
||||
private async Task<LoginWithLNURLAuthViewModel> BuildLNURLAuthViewModel(bool rememberMe, ApplicationUser user)
|
||||
{
|
||||
if (_btcPayServerEnvironment.IsSecure(HttpContext))
|
||||
|
@ -273,15 +270,14 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
return null;
|
||||
}
|
||||
return new LoginWithLNURLAuthViewModel()
|
||||
return new LoginWithLNURLAuthViewModel
|
||||
{
|
||||
|
||||
RememberMe = rememberMe,
|
||||
UserId = user.Id,
|
||||
LNURLEndpoint = new Uri(_linkGenerator.GetUriByAction(
|
||||
action: nameof(UILNURLAuthController.LoginResponse),
|
||||
controller: "UILNURLAuth",
|
||||
values: new { userId = user.Id, action = "login", tag = "login", k1 = Encoders.Hex.EncodeData(r) }, Request.Scheme, Request.Host, Request.PathBase))
|
||||
action: nameof(UILNURLAuthController.LoginResponse),
|
||||
controller: "UILNURLAuth",
|
||||
values: new { userId = user.Id, action = "login", tag = "login", k1 = Encoders.Hex.EncodeData(r) }, Request.Scheme, Request.Host, Request.PathBase) ?? string.Empty)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
@ -298,14 +294,18 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
|
||||
ViewData["ReturnUrl"] = returnUrl;
|
||||
var errorMessage = "Invalid login attempt.";
|
||||
var user = await _userManager.FindByIdAsync(viewModel.UserId);
|
||||
|
||||
if (user == null)
|
||||
if (!UserService.TryCanLogin(user, out var message))
|
||||
{
|
||||
return NotFound();
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
|
||||
Message = message
|
||||
});
|
||||
return RedirectToAction("Login");
|
||||
}
|
||||
|
||||
var errorMessage = string.Empty;
|
||||
try
|
||||
{
|
||||
var k1 = Encoders.Hex.DecodeData(viewModel.LNURLEndpoint.ParseQueryString().Get("k1"));
|
||||
|
@ -313,34 +313,33 @@ namespace BTCPayServer.Controllers
|
|||
storedk1.SequenceEqual(k1))
|
||||
{
|
||||
_lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out _);
|
||||
await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2");
|
||||
_logger.LogInformation("User logged in.");
|
||||
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
|
||||
_logger.LogInformation("User logged in");
|
||||
return RedirectToLocal(returnUrl);
|
||||
}
|
||||
|
||||
errorMessage = "Invalid login attempt.";
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
errorMessage = e.Message;
|
||||
}
|
||||
|
||||
ModelState.AddModelError(string.Empty, errorMessage);
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel()
|
||||
if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
|
||||
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
|
||||
ModelState.AddModelError(string.Empty, errorMessage);
|
||||
}
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel
|
||||
{
|
||||
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user!.Id) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
|
||||
LoginWithLNURLAuthViewModel = viewModel,
|
||||
LoginWith2FaViewModel = !user.TwoFactorEnabled
|
||||
? null
|
||||
: new LoginWith2faViewModel()
|
||||
: new LoginWith2faViewModel
|
||||
{
|
||||
RememberMe = viewModel.RememberMe
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("/login/fido2")]
|
||||
[AllowAnonymous]
|
||||
[ValidateAntiForgeryToken]
|
||||
|
@ -352,44 +351,50 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
|
||||
ViewData["ReturnUrl"] = returnUrl;
|
||||
var errorMessage = "Invalid login attempt.";
|
||||
var user = await _userManager.FindByIdAsync(viewModel.UserId);
|
||||
|
||||
if (user == null)
|
||||
if (!UserService.TryCanLogin(user, out var message))
|
||||
{
|
||||
return NotFound();
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
|
||||
Message = message
|
||||
});
|
||||
return RedirectToAction("Login");
|
||||
}
|
||||
|
||||
var errorMessage = string.Empty;
|
||||
try
|
||||
{
|
||||
if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>()))
|
||||
{
|
||||
await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2");
|
||||
_logger.LogInformation("User logged in.");
|
||||
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
|
||||
_logger.LogInformation("User logged in");
|
||||
return RedirectToLocal(returnUrl);
|
||||
}
|
||||
|
||||
errorMessage = "Invalid login attempt.";
|
||||
}
|
||||
catch (Fido2VerificationException e)
|
||||
{
|
||||
errorMessage = e.Message;
|
||||
}
|
||||
|
||||
ModelState.AddModelError(string.Empty, errorMessage);
|
||||
if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, errorMessage);
|
||||
}
|
||||
viewModel.Response = null;
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel()
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel
|
||||
{
|
||||
LoginWithFido2ViewModel = viewModel,
|
||||
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
|
||||
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user!.Id) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
|
||||
LoginWith2FaViewModel = !user.TwoFactorEnabled
|
||||
? null
|
||||
: new LoginWith2faViewModel()
|
||||
: new LoginWith2faViewModel
|
||||
{
|
||||
RememberMe = viewModel.RememberMe
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/login/2fa")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> LoginWith2fa(bool rememberMe, string returnUrl = null)
|
||||
|
@ -401,7 +406,6 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
// Ensure the user has gone through the username & password screen first
|
||||
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
throw new ApplicationException($"Unable to load two-factor authentication user.");
|
||||
|
@ -409,11 +413,11 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
ViewData["ReturnUrl"] = returnUrl;
|
||||
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel()
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel
|
||||
{
|
||||
LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe },
|
||||
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null,
|
||||
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
|
||||
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
|
||||
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -437,32 +441,32 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
||||
}
|
||||
if (!UserService.TryCanLogin(user, out var message))
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Warning,
|
||||
Message = message
|
||||
});
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty, StringComparison.InvariantCulture).Replace("-", string.Empty, StringComparison.InvariantCulture);
|
||||
|
||||
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
_logger.LogInformation("User with ID {UserId} logged in with 2fa.", user.Id);
|
||||
_logger.LogInformation("User with ID {UserId} logged in with 2fa", user.Id);
|
||||
return RedirectToLocal(returnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
|
||||
_logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}", user.Id);
|
||||
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel
|
||||
{
|
||||
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
|
||||
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}.", user.Id);
|
||||
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
|
||||
return View("SecondaryLogin", new SecondaryLoginViewModel()
|
||||
{
|
||||
LoginWith2FaViewModel = model,
|
||||
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null,
|
||||
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
|
||||
});
|
||||
}
|
||||
LoginWith2FaViewModel = model,
|
||||
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
|
||||
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/login/recovery-code")]
|
||||
|
@ -504,30 +508,35 @@ namespace BTCPayServer.Controllers
|
|||
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
|
||||
if (user == null)
|
||||
{
|
||||
throw new ApplicationException($"Unable to load two-factor authentication user.");
|
||||
throw new ApplicationException("Unable to load two-factor authentication user.");
|
||||
}
|
||||
if (!UserService.TryCanLogin(user, out var message))
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Warning,
|
||||
Message = message
|
||||
});
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty, StringComparison.InvariantCulture);
|
||||
|
||||
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
_logger.LogInformation("User with ID {UserId} logged in with a recovery code.", user.Id);
|
||||
_logger.LogInformation("User with ID {UserId} logged in with a recovery code", user.Id);
|
||||
return RedirectToLocal(returnUrl);
|
||||
}
|
||||
if (result.IsLockedOut)
|
||||
{
|
||||
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
|
||||
_logger.LogWarning("User with ID {UserId} account locked out", user.Id);
|
||||
|
||||
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id);
|
||||
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
|
||||
return View();
|
||||
}
|
||||
|
||||
_logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id);
|
||||
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
|
||||
return View();
|
||||
}
|
||||
|
||||
[HttpGet("/login/lockout")]
|
||||
|
@ -540,7 +549,7 @@ namespace BTCPayServer.Controllers
|
|||
[HttpGet("/register")]
|
||||
[AllowAnonymous]
|
||||
[RateLimitsFilter(ZoneLimits.Register, Scope = RateLimitsScope.RemoteAddress)]
|
||||
public IActionResult Register(string returnUrl = null, bool logon = true)
|
||||
public IActionResult Register(string returnUrl = null)
|
||||
{
|
||||
if (!CanLoginOrRegister())
|
||||
{
|
||||
|
@ -567,32 +576,36 @@ namespace BTCPayServer.Controllers
|
|||
var policies = await _SettingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
|
||||
if (policies.LockSubscription && !User.IsInRole(Roles.ServerAdmin))
|
||||
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
|
||||
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
var anyAdmin = (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any();
|
||||
var isFirstAdmin = !anyAdmin || (model.IsAdmin && _Options.CheatMode);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = model.Email,
|
||||
Email = model.Email,
|
||||
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
|
||||
Created = DateTimeOffset.UtcNow
|
||||
RequiresApproval = policies.RequiresUserApproval,
|
||||
Created = DateTimeOffset.UtcNow,
|
||||
Approved = isFirstAdmin // auto-approve first admin
|
||||
};
|
||||
var result = await _userManager.CreateAsync(user, model.Password);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var admin = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
|
||||
if (admin.Count == 0 || (model.IsAdmin && _Options.CheatMode))
|
||||
if (isFirstAdmin)
|
||||
{
|
||||
await _RoleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin));
|
||||
await _userManager.AddToRoleAsync(user, Roles.ServerAdmin);
|
||||
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>();
|
||||
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
|
||||
settings.FirstRun = false;
|
||||
await _SettingsRepository.UpdateSetting<ThemeSettings>(settings);
|
||||
await _SettingsRepository.UpdateSetting(settings);
|
||||
|
||||
await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs);
|
||||
RegisteredAdmin = true;
|
||||
}
|
||||
|
||||
_eventAggregator.Publish(new UserRegisteredEvent()
|
||||
_eventAggregator.Publish(new UserRegisteredEvent
|
||||
{
|
||||
RequestUri = Request.GetAbsoluteRootUri(),
|
||||
User = user,
|
||||
|
@ -600,19 +613,29 @@ namespace BTCPayServer.Controllers
|
|||
});
|
||||
RegisteredUserId = user.Id;
|
||||
|
||||
if (!policies.RequiresConfirmedEmail)
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Account created.";
|
||||
if (policies.RequiresConfirmedEmail)
|
||||
{
|
||||
if (logon)
|
||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||
TempData[WellKnownTempData.SuccessMessage] += " Please confirm your email.";
|
||||
}
|
||||
if (policies.RequiresUserApproval)
|
||||
{
|
||||
TempData[WellKnownTempData.SuccessMessage] += " The new account requires approval by an admin before you can log in.";
|
||||
}
|
||||
if (policies.RequiresConfirmedEmail || policies.RequiresUserApproval)
|
||||
{
|
||||
return RedirectToAction(nameof(Login));
|
||||
}
|
||||
if (logon)
|
||||
{
|
||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||
return RedirectToLocal(returnUrl);
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Account created, please confirm your email";
|
||||
return View();
|
||||
}
|
||||
}
|
||||
AddErrors(result);
|
||||
else
|
||||
{
|
||||
AddErrors(result);
|
||||
}
|
||||
}
|
||||
|
||||
// If we got this far, something failed, redisplay form
|
||||
|
@ -628,8 +651,8 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
await _signInManager.SignOutAsync();
|
||||
HttpContext.DeleteUserPrefsCookie();
|
||||
_logger.LogInformation("User logged out.");
|
||||
return RedirectToAction(nameof(UIAccountController.Login));
|
||||
_logger.LogInformation("User logged out");
|
||||
return RedirectToAction(nameof(Login));
|
||||
}
|
||||
|
||||
[HttpGet("/register/confirm-email")]
|
||||
|
@ -650,7 +673,7 @@ namespace BTCPayServer.Controllers
|
|||
if (!await _userManager.HasPasswordAsync(user))
|
||||
{
|
||||
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Info,
|
||||
Message = "Your email has been confirmed but you still need to set your password."
|
||||
|
@ -660,7 +683,7 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Message = "Your email has been confirmed."
|
||||
|
@ -687,12 +710,12 @@ namespace BTCPayServer.Controllers
|
|||
if (ModelState.IsValid)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(model.Email);
|
||||
if (user == null || (user.RequiresEmailConfirmation && !(await _userManager.IsEmailConfirmedAsync(user))))
|
||||
if (!UserService.TryCanLogin(user, out _))
|
||||
{
|
||||
// Don't reveal that the user does not exist or is not confirmed
|
||||
return RedirectToAction(nameof(ForgotPasswordConfirmation));
|
||||
}
|
||||
_eventAggregator.Publish(new UserPasswordResetRequestedEvent()
|
||||
_eventAggregator.Publish(new UserPasswordResetRequestedEvent
|
||||
{
|
||||
User = user,
|
||||
RequestUri = Request.GetAbsoluteRootUri()
|
||||
|
@ -740,16 +763,16 @@ namespace BTCPayServer.Controllers
|
|||
return View(model);
|
||||
}
|
||||
var user = await _userManager.FindByEmailAsync(model.Email);
|
||||
if (user == null)
|
||||
if (!UserService.TryCanLogin(user, out _))
|
||||
{
|
||||
// Don't reveal that the user does not exist
|
||||
return RedirectToAction(nameof(Login));
|
||||
}
|
||||
|
||||
var result = await _userManager.ResetPasswordAsync(user, model.Code, model.Password);
|
||||
var result = await _userManager.ResetPasswordAsync(user!, model.Code, model.Password);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Message = "Password successfully set."
|
||||
|
@ -800,7 +823,7 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
private void SetInsecureFlags()
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||
Message = "You cannot login over an insecure connection. Please use HTTPS or Tor."
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
@ -8,25 +7,21 @@ using BTCPayServer.Abstractions.Extensions;
|
|||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.ServerViewModels;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MimeKit;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
public partial class UIServerController
|
||||
{
|
||||
[Route("server/users")]
|
||||
[HttpGet("server/users")]
|
||||
public async Task<IActionResult> ListUsers(
|
||||
[FromServices] RoleManager<IdentityRole> roleManager,
|
||||
UsersViewModel model,
|
||||
string sortOrder = null
|
||||
)
|
||||
UsersViewModel model,
|
||||
string sortOrder = null)
|
||||
{
|
||||
model = this.ParseListQuery(model ?? new UsersViewModel());
|
||||
|
||||
|
@ -64,7 +59,8 @@ namespace BTCPayServer.Controllers
|
|||
Name = u.UserName,
|
||||
Email = u.Email,
|
||||
Id = u.Id,
|
||||
Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation,
|
||||
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
|
||||
Approved = u.RequiresApproval ? u.Approved : null,
|
||||
Created = u.Created,
|
||||
Roles = u.UserRoles.Select(role => role.RoleId),
|
||||
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime
|
||||
|
@ -74,44 +70,67 @@ namespace BTCPayServer.Controllers
|
|||
return View(model);
|
||||
}
|
||||
|
||||
[Route("server/users/{userId}")]
|
||||
[HttpGet("server/users/{userId}")]
|
||||
public new async Task<IActionResult> User(string userId)
|
||||
{
|
||||
var user = await _UserManager.FindByIdAsync(userId);
|
||||
if (user == null)
|
||||
return NotFound();
|
||||
var roles = await _UserManager.GetRolesAsync(user);
|
||||
var userVM = new UsersViewModel.UserViewModel
|
||||
var model = new UsersViewModel.UserViewModel
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
Verified = user.EmailConfirmed || !user.RequiresEmailConfirmation,
|
||||
EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
|
||||
Approved = user.RequiresApproval ? user.Approved : null,
|
||||
IsAdmin = Roles.HasServerAdmin(roles)
|
||||
};
|
||||
return View(userVM);
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[Route("server/users/{userId}")]
|
||||
[HttpPost]
|
||||
[HttpPost("server/users/{userId}")]
|
||||
public new async Task<IActionResult> User(string userId, UsersViewModel.UserViewModel viewModel)
|
||||
{
|
||||
var user = await _UserManager.FindByIdAsync(userId);
|
||||
if (user == null)
|
||||
return NotFound();
|
||||
|
||||
bool? propertiesChanged = null;
|
||||
bool? adminStatusChanged = null;
|
||||
bool? approvalStatusChanged = null;
|
||||
|
||||
if (user.RequiresApproval && viewModel.Approved.HasValue)
|
||||
{
|
||||
approvalStatusChanged = await _userService.SetUserApproval(user.Id, viewModel.Approved.Value, Request.GetAbsoluteRootUri());
|
||||
}
|
||||
if (user.RequiresEmailConfirmation && viewModel.EmailConfirmed.HasValue && user.EmailConfirmed != viewModel.EmailConfirmed)
|
||||
{
|
||||
user.EmailConfirmed = viewModel.EmailConfirmed.Value;
|
||||
propertiesChanged = true;
|
||||
}
|
||||
|
||||
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
|
||||
var roles = await _UserManager.GetRolesAsync(user);
|
||||
var wasAdmin = Roles.HasServerAdmin(roles);
|
||||
if (!viewModel.IsAdmin && admins.Count == 1 && wasAdmin)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "This is the only Admin, so their role can't be removed until another Admin is added.";
|
||||
return View(viewModel); // return
|
||||
return View(viewModel);
|
||||
}
|
||||
|
||||
if (viewModel.IsAdmin != wasAdmin)
|
||||
{
|
||||
var success = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin);
|
||||
if (success)
|
||||
adminStatusChanged = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin);
|
||||
}
|
||||
|
||||
if (propertiesChanged is true)
|
||||
{
|
||||
propertiesChanged = await _UserManager.UpdateAsync(user) is { Succeeded: true };
|
||||
}
|
||||
|
||||
if (propertiesChanged.HasValue || adminStatusChanged.HasValue || approvalStatusChanged.HasValue)
|
||||
{
|
||||
if (propertiesChanged is not false && adminStatusChanged is not false && approvalStatusChanged is not false)
|
||||
{
|
||||
TempData[WellKnownTempData.SuccessMessage] = "User successfully updated";
|
||||
}
|
||||
|
@ -121,23 +140,22 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(User), new { userId = userId });
|
||||
return RedirectToAction(nameof(User), new { userId });
|
||||
}
|
||||
|
||||
[Route("server/users/new")]
|
||||
[HttpGet]
|
||||
[HttpGet("server/users/new")]
|
||||
public IActionResult CreateUser()
|
||||
{
|
||||
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
|
||||
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
||||
return View();
|
||||
}
|
||||
|
||||
[Route("server/users/new")]
|
||||
[HttpPost]
|
||||
[HttpPost("server/users/new")]
|
||||
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
|
||||
{
|
||||
var requiresConfirmedEmail = _policiesSettings.RequiresConfirmedEmail;
|
||||
ViewData["AllowRequestEmailConfirmation"] = requiresConfirmedEmail;
|
||||
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
|
||||
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
||||
if (!_Options.CheatMode)
|
||||
model.IsAdmin = false;
|
||||
if (ModelState.IsValid)
|
||||
|
@ -148,7 +166,9 @@ namespace BTCPayServer.Controllers
|
|||
UserName = model.Email,
|
||||
Email = model.Email,
|
||||
EmailConfirmed = model.EmailConfirmed,
|
||||
RequiresEmailConfirmation = requiresConfirmedEmail,
|
||||
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
|
||||
RequiresApproval = _policiesSettings.RequiresUserApproval,
|
||||
Approved = model.Approved,
|
||||
Created = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
|
@ -223,7 +243,6 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
if (await _userService.IsUserTheOnlyOneAdmin(user))
|
||||
{
|
||||
// return
|
||||
return View("Confirm", new ConfirmModel("Delete admin",
|
||||
$"Unable to proceed: As the user <strong>{Html.Encode(user.Email)}</strong> is the last enabled admin, it cannot be removed."));
|
||||
}
|
||||
|
@ -281,6 +300,29 @@ namespace BTCPayServer.Controllers
|
|||
return RedirectToAction(nameof(ListUsers));
|
||||
}
|
||||
|
||||
[HttpGet("server/users/{userId}/approve")]
|
||||
public async Task<IActionResult> ApproveUser(string userId, bool approved)
|
||||
{
|
||||
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
|
||||
if (user == null)
|
||||
return NotFound();
|
||||
|
||||
return View("Confirm", new ConfirmModel($"{(approved ? "Approve" : "Unapprove")} user", $"The user <strong>{Html.Encode(user.Email)}</strong> will be {(approved ? "approved" : "unapproved")}. Are you sure?", (approved ? "Approve" : "Unapprove")));
|
||||
}
|
||||
|
||||
[HttpPost("server/users/{userId}/approve")]
|
||||
public async Task<IActionResult> ApproveUserPost(string userId, bool approved)
|
||||
{
|
||||
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
|
||||
if (user == null)
|
||||
return NotFound();
|
||||
|
||||
await _userService.SetUserApproval(userId, approved, Request.GetAbsoluteRootUri());
|
||||
|
||||
TempData[WellKnownTempData.SuccessMessage] = $"User {(approved ? "approved" : "unapproved")}";
|
||||
return RedirectToAction(nameof(ListUsers));
|
||||
}
|
||||
|
||||
[HttpGet("server/users/{userId}/verification-email")]
|
||||
public async Task<IActionResult> SendVerificationEmail(string userId)
|
||||
{
|
||||
|
@ -332,5 +374,8 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
[Display(Name = "Email confirmed?")]
|
||||
public bool EmailConfirmed { get; set; }
|
||||
|
||||
[Display(Name = "User approved?")]
|
||||
public bool Approved { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
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 BTCPayServer.Configuration;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.NTag424;
|
||||
using BTCPayServer.Services;
|
||||
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>");
|
||||
}
|
||||
|
||||
public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||
{
|
||||
emailSender.SendEmail(address, "Your account has been approved",
|
||||
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>");
|
||||
}
|
||||
|
||||
public static void SendSetPasswordConfirmation(this IEmailSender emailSender, MailboxAddress address, string link, bool newPassword)
|
||||
{
|
||||
var subject = $"{(newPassword ? "Set" : "Update")} Password";
|
||||
|
|
|
@ -29,6 +29,11 @@ namespace Microsoft.AspNetCore.Mvc
|
|||
new { userId, code }, scheme, host, pathbase);
|
||||
}
|
||||
|
||||
public static string LoginLink(this LinkGenerator urlHelper, string scheme, HostString host, string pathbase)
|
||||
{
|
||||
return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", null , scheme, host, pathbase);
|
||||
}
|
||||
|
||||
public static string ResetPasswordCallbackLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
||||
{
|
||||
return urlHelper.GetUriByAction(
|
||||
|
|
|
@ -6,6 +6,8 @@ using BTCPayServer.Events;
|
|||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using BTCPayServer.Services.Notifications;
|
||||
using BTCPayServer.Services.Notifications.Blobs;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -19,20 +21,27 @@ namespace BTCPayServer.HostedServices
|
|||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly EmailSenderFactory _emailSenderFactory;
|
||||
private readonly NotificationSender _notificationSender;
|
||||
private readonly LinkGenerator _generator;
|
||||
|
||||
|
||||
public UserEventHostedService(EventAggregator eventAggregator, UserManager<ApplicationUser> userManager,
|
||||
EmailSenderFactory emailSenderFactory, LinkGenerator generator, Logs logs) : base(eventAggregator, logs)
|
||||
public UserEventHostedService(
|
||||
EventAggregator eventAggregator,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
NotificationSender notificationSender,
|
||||
LinkGenerator generator,
|
||||
Logs logs) : base(eventAggregator, logs)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_emailSenderFactory = emailSenderFactory;
|
||||
_notificationSender = notificationSender;
|
||||
_generator = generator;
|
||||
}
|
||||
|
||||
protected override void SubscribeToEvents()
|
||||
{
|
||||
Subscribe<UserRegisteredEvent>();
|
||||
Subscribe<UserApprovedEvent>();
|
||||
Subscribe<UserPasswordResetRequestedEvent>();
|
||||
}
|
||||
|
||||
|
@ -40,30 +49,39 @@ namespace BTCPayServer.HostedServices
|
|||
{
|
||||
string code;
|
||||
string callbackUrl;
|
||||
Uri uri;
|
||||
HostString host;
|
||||
ApplicationUser user;
|
||||
MailboxAddress address;
|
||||
IEmailSender emailSender;
|
||||
UserPasswordResetRequestedEvent userPasswordResetRequestedEvent;
|
||||
switch (evt)
|
||||
{
|
||||
case UserRegisteredEvent userRegisteredEvent:
|
||||
user = userRegisteredEvent.User;
|
||||
Logs.PayServer.LogInformation(
|
||||
$"A new user just registered {userRegisteredEvent.User.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}");
|
||||
if (!userRegisteredEvent.User.EmailConfirmed && userRegisteredEvent.User.RequiresEmailConfirmation)
|
||||
$"A new user just registered {user.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}");
|
||||
if (user.RequiresApproval && !user.Approved)
|
||||
{
|
||||
code = await _userManager.GenerateEmailConfirmationTokenAsync(userRegisteredEvent.User);
|
||||
callbackUrl = _generator.EmailConfirmationLink(userRegisteredEvent.User.Id, code,
|
||||
userRegisteredEvent.RequestUri.Scheme,
|
||||
new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port),
|
||||
userRegisteredEvent.RequestUri.PathAndQuery);
|
||||
await _notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
|
||||
}
|
||||
if (!user.EmailConfirmed && user.RequiresEmailConfirmation)
|
||||
{
|
||||
uri = userRegisteredEvent.RequestUri;
|
||||
host = new HostString(uri.Host, uri.Port);
|
||||
code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
callbackUrl = _generator.EmailConfirmationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
|
||||
userRegisteredEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
|
||||
address = userRegisteredEvent.User.GetMailboxAddress();
|
||||
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(address, callbackUrl);
|
||||
address = user.GetMailboxAddress();
|
||||
emailSender = await _emailSenderFactory.GetEmailSender();
|
||||
emailSender.SendEmailConfirmation(address, callbackUrl);
|
||||
}
|
||||
else if (!await _userManager.HasPasswordAsync(userRegisteredEvent.User))
|
||||
{
|
||||
userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent()
|
||||
userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent
|
||||
{
|
||||
CallbackUrlGenerated = userRegisteredEvent.CallbackUrlGenerated,
|
||||
User = userRegisteredEvent.User,
|
||||
User = user,
|
||||
RequestUri = userRegisteredEvent.RequestUri
|
||||
};
|
||||
goto passwordSetter;
|
||||
|
@ -72,22 +90,33 @@ namespace BTCPayServer.HostedServices
|
|||
{
|
||||
userRegisteredEvent.CallbackUrlGenerated?.SetResult(null);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case UserApprovedEvent userApprovedEvent:
|
||||
if (userApprovedEvent.Approved)
|
||||
{
|
||||
uri = userApprovedEvent.RequestUri;
|
||||
host = new HostString(uri.Host, uri.Port);
|
||||
address = userApprovedEvent.User.GetMailboxAddress();
|
||||
callbackUrl = _generator.LoginLink(uri.Scheme, host, uri.PathAndQuery);
|
||||
emailSender = await _emailSenderFactory.GetEmailSender();
|
||||
emailSender.SendApprovalConfirmation(address, callbackUrl);
|
||||
}
|
||||
break;
|
||||
|
||||
case UserPasswordResetRequestedEvent userPasswordResetRequestedEvent2:
|
||||
userPasswordResetRequestedEvent = userPasswordResetRequestedEvent2;
|
||||
passwordSetter:
|
||||
code = await _userManager.GeneratePasswordResetTokenAsync(userPasswordResetRequestedEvent.User);
|
||||
var newPassword = await _userManager.HasPasswordAsync(userPasswordResetRequestedEvent.User);
|
||||
callbackUrl = _generator.ResetPasswordCallbackLink(userPasswordResetRequestedEvent.User.Id, code,
|
||||
userPasswordResetRequestedEvent.RequestUri.Scheme,
|
||||
new HostString(userPasswordResetRequestedEvent.RequestUri.Host,
|
||||
userPasswordResetRequestedEvent.RequestUri.Port),
|
||||
userPasswordResetRequestedEvent.RequestUri.PathAndQuery);
|
||||
uri = userPasswordResetRequestedEvent.RequestUri;
|
||||
host = new HostString(uri.Host, uri.Port);
|
||||
user = userPasswordResetRequestedEvent.User;
|
||||
code = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var newPassword = await _userManager.HasPasswordAsync(user);
|
||||
callbackUrl = _generator.ResetPasswordCallbackLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
|
||||
userPasswordResetRequestedEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
|
||||
address = userPasswordResetRequestedEvent.User.GetMailboxAddress();
|
||||
(await _emailSenderFactory.GetEmailSender())
|
||||
.SendSetPasswordConfirmation(address, callbackUrl, newPassword);
|
||||
address = user.GetMailboxAddress();
|
||||
emailSender = await _emailSenderFactory.GetEmailSender();
|
||||
emailSender.SendSetPasswordConfirmation(address, callbackUrl, newPassword);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -436,8 +436,8 @@ namespace BTCPayServer.Hosting
|
|||
services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();
|
||||
|
||||
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
|
||||
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
|
||||
services.AddSingleton<INotificationHandler, PluginUpdateNotification.Handler>();
|
||||
|
||||
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
|
||||
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
|
||||
services.AddSingleton<INotificationHandler, ExternalPayoutTransactionNotification.Handler>();
|
||||
|
|
|
@ -11,7 +11,8 @@ namespace BTCPayServer.Models.ServerViewModels
|
|||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Email { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
public bool? EmailConfirmed { get; set; }
|
||||
public bool? Approved { get; set; }
|
||||
public bool Disabled { get; set; }
|
||||
public bool IsAdmin { get; set; }
|
||||
public DateTimeOffset? Created { get; set; }
|
||||
|
|
|
@ -7,6 +7,7 @@ using System.Text.Encodings.Web;
|
|||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
@ -58,14 +59,12 @@ namespace BTCPayServer.Security.Greenfield
|
|||
return AuthenticateResult.NoResult();
|
||||
|
||||
var key = await _apiKeyRepository.GetKey(apiKey, true);
|
||||
|
||||
if (key == null || await _userManager.IsLockedOutAsync(key.User))
|
||||
if (!UserService.TryCanLogin(key?.User, out var error))
|
||||
{
|
||||
return AuthenticateResult.Fail("ApiKey authentication failed");
|
||||
return AuthenticateResult.Fail($"ApiKey authentication failed: {error}");
|
||||
}
|
||||
List<Claim> claims = new List<Claim>();
|
||||
claims.Add(new Claim(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId));
|
||||
|
||||
var claims = new List<Claim> { new (_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId) };
|
||||
claims.AddRange((await _userManager.GetRolesAsync(key.User)).Select(s => new Claim(_identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, s)));
|
||||
claims.AddRange(Permission.ToPermissions(key.GetBlob()?.Permissions ?? Array.Empty<string>()).Select(permission =>
|
||||
new Claim(GreenfieldConstants.ClaimTypes.Permission, permission.ToString())));
|
||||
|
|
|
@ -7,6 +7,7 @@ using System.Text.Encodings.Web;
|
|||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -66,6 +67,10 @@ namespace BTCPayServer.Security.Greenfield
|
|||
.FirstOrDefaultAsync(applicationUser =>
|
||||
applicationUser.NormalizedUserName == _userManager.NormalizeName(username));
|
||||
|
||||
if (!UserService.TryCanLogin(user, out var error))
|
||||
{
|
||||
return AuthenticateResult.Fail($"Basic authentication failed: {error}");
|
||||
}
|
||||
if (user.Fido2Credentials.Any())
|
||||
{
|
||||
return AuthenticateResult.Fail("Cannot use Basic authentication with multi-factor is enabled.");
|
||||
|
|
|
@ -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.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using BTCPayServer.Validation;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
@ -14,6 +14,19 @@ namespace BTCPayServer.Services
|
|||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
|
||||
[Display(Name = "Disable new user registration on the server")]
|
||||
public bool LockSubscription { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
[Display(Name = "Enable new user registration on the server")]
|
||||
public bool EnableRegistration
|
||||
{
|
||||
get => !LockSubscription;
|
||||
set { LockSubscription = !value; }
|
||||
}
|
||||
|
||||
[DefaultValue(true)]
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
|
||||
[Display(Name = "Require new users to be approved by an admin after registration")]
|
||||
public bool RequiresUserApproval { get; set; } = true;
|
||||
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
|
||||
[Display(Name = "Discourage search engines from indexing this site")]
|
||||
|
@ -40,6 +53,14 @@ namespace BTCPayServer.Services
|
|||
|
||||
[Display(Name = "Disable non-admins access to the user creation API endpoint")]
|
||||
public bool DisableNonAdminCreateUserApi { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
[Display(Name = "Non-admins can access the user creation API endpoint")]
|
||||
public bool EnableNonAdminCreateUserApi
|
||||
{
|
||||
get => !DisableNonAdminCreateUserApi;
|
||||
set { DisableNonAdminCreateUserApi = !value; }
|
||||
}
|
||||
|
||||
public const string DefaultPluginSource = "https://plugin-builder.btcpayserver.org";
|
||||
[UriAttribute]
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Storage.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
@ -20,6 +23,7 @@ namespace BTCPayServer.Services
|
|||
private readonly StoredFileRepository _storedFileRepository;
|
||||
private readonly FileService _fileService;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
|
||||
private readonly ILogger<UserService> _logger;
|
||||
|
||||
|
@ -27,6 +31,7 @@ namespace BTCPayServer.Services
|
|||
IServiceProvider serviceProvider,
|
||||
StoredFileRepository storedFileRepository,
|
||||
FileService fileService,
|
||||
EventAggregator eventAggregator,
|
||||
StoreRepository storeRepository,
|
||||
ApplicationDbContextFactory applicationDbContextFactory,
|
||||
ILogger<UserService> logger)
|
||||
|
@ -34,6 +39,7 @@ namespace BTCPayServer.Services
|
|||
_serviceProvider = serviceProvider;
|
||||
_storedFileRepository = storedFileRepository;
|
||||
_fileService = fileService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_storeRepository = storeRepository;
|
||||
_applicationDbContextFactory = applicationDbContextFactory;
|
||||
_logger = logger;
|
||||
|
@ -46,26 +52,89 @@ namespace BTCPayServer.Services
|
|||
(userRole, role) => role.Name).ToArray()))).ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
public static ApplicationUserData FromModel(ApplicationUser data, string?[] roles)
|
||||
{
|
||||
return new ApplicationUserData()
|
||||
return new ApplicationUserData
|
||||
{
|
||||
Id = data.Id,
|
||||
Email = data.Email,
|
||||
EmailConfirmed = data.EmailConfirmed,
|
||||
RequiresEmailConfirmation = data.RequiresEmailConfirmation,
|
||||
Approved = data.Approved,
|
||||
RequiresApproval = data.RequiresApproval,
|
||||
Created = data.Created,
|
||||
Roles = roles,
|
||||
Disabled = data.LockoutEnabled && data.LockoutEnd is not null && DateTimeOffset.UtcNow < data.LockoutEnd.Value.UtcDateTime
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsDisabled(ApplicationUser user)
|
||||
private static bool IsEmailConfirmed(ApplicationUser user)
|
||||
{
|
||||
return user.EmailConfirmed || !user.RequiresEmailConfirmation;
|
||||
}
|
||||
|
||||
private static bool IsApproved(ApplicationUser user)
|
||||
{
|
||||
return user.Approved || !user.RequiresApproval;
|
||||
}
|
||||
|
||||
private static bool IsDisabled(ApplicationUser user)
|
||||
{
|
||||
return user.LockoutEnabled && user.LockoutEnd is not null &&
|
||||
DateTimeOffset.UtcNow < user.LockoutEnd.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
public static bool TryCanLogin([NotNullWhen(true)] ApplicationUser? user, [MaybeNullWhen(true)] out string error)
|
||||
{
|
||||
error = null;
|
||||
if (user == null)
|
||||
{
|
||||
error = "Invalid login attempt.";
|
||||
return false;
|
||||
}
|
||||
if (!IsEmailConfirmed(user))
|
||||
{
|
||||
error = "You must have a confirmed email to log in.";
|
||||
return false;
|
||||
}
|
||||
if (!IsApproved(user))
|
||||
{
|
||||
error = "Your user account requires approval by an admin before you can log in.";
|
||||
return false;
|
||||
}
|
||||
if (IsDisabled(user))
|
||||
{
|
||||
error = "Your user account is currently disabled.";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> SetUserApproval(string userId, bool approved, Uri requestUri)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var user = await userManager.FindByIdAsync(userId);
|
||||
if (user is null || !user.RequiresApproval || user.Approved == approved)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
user.Approved = approved;
|
||||
var succeeded = await userManager.UpdateAsync(user) is { Succeeded: true };
|
||||
if (succeeded)
|
||||
{
|
||||
_logger.LogInformation("User {UserId} is now {Status}", user.Id, approved ? "approved" : "unapproved");
|
||||
_eventAggregator.Publish(new UserApprovedEvent { User = user, Approved = approved, RequestUri = requestUri });
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Failed to {Action} user {UserId}", approved ? "approve" : "unapprove", user.Id);
|
||||
}
|
||||
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
public async Task<bool?> ToggleUser(string userId, DateTimeOffset? lockedOutDeadline)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
|
@ -163,7 +232,6 @@ namespace BTCPayServer.Services
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> IsUserTheOnlyOneAdmin(ApplicationUser user)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
|
@ -175,7 +243,7 @@ namespace BTCPayServer.Services
|
|||
}
|
||||
var adminUsers = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
|
||||
var enabledAdminUsers = adminUsers
|
||||
.Where(applicationUser => !IsDisabled(applicationUser))
|
||||
.Where(applicationUser => !IsDisabled(applicationUser) && IsApproved(applicationUser))
|
||||
.Select(applicationUser => applicationUser.Id).ToList();
|
||||
|
||||
return enabledAdminUsers.Count == 1 && enabledAdminUsers.Contains(user.Id);
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
</p>
|
||||
|
||||
<form asp-action="ForgotPassword" method="post">
|
||||
<div asp-validation-summary="All"></div>
|
||||
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Email" class="form-label"></label>
|
||||
<input asp-for="Email" class="form-control" />
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
@using BTCPayServer.Abstractions.Extensions
|
||||
@model DateTimeOffset?
|
||||
@model DateTimeOffset?
|
||||
@{
|
||||
ViewData["Title"] = "Account disabled";
|
||||
Layout = "_LayoutSignedOut";
|
||||
|
|
|
@ -9,16 +9,16 @@
|
|||
|
||||
<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="login-form" asp-action="Login">
|
||||
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)">
|
||||
<div asp-validation-summary="ModelOnly"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Email" class="form-label"></label>
|
||||
<input asp-for="Email" class="form-control" required autofocus/>
|
||||
<input asp-for="Email" class="form-control" required autofocus />
|
||||
<span asp-validation-for="Email" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="d-flex justify-content-between">
|
||||
<label asp-for="Password" class="form-label"></label>
|
||||
<a asp-action="ForgotPassword" >Forgot password?</a>
|
||||
<a asp-action="ForgotPassword" tabindex="-1">Forgot password?</a>
|
||||
</div>
|
||||
<div class="input-group d-flex">
|
||||
<input asp-for="Password" class="form-control" required />
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
<form asp-route-returnUrl="@ViewData["ReturnUrl"]" asp-route-logon="true" method="post">
|
||||
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)" >
|
||||
<div asp-validation-summary="ModelOnly"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Email" class="form-label"></label>
|
||||
<input asp-for="Email" class="form-control" required autofocus />
|
||||
|
|
|
@ -13,9 +13,9 @@
|
|||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
|
||||
@if (Model.LoginWith2FaViewModel != null && Model.LoginWithFido2ViewModel != null&& Model.LoginWithLNURLAuthViewModel != null)
|
||||
@if (Model.LoginWith2FaViewModel != null && Model.LoginWithFido2ViewModel != null && Model.LoginWithLNURLAuthViewModel != null)
|
||||
{
|
||||
<div asp-validation-summary="ModelOnly"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
|
||||
}
|
||||
else if (Model.LoginWith2FaViewModel == null && Model.LoginWithFido2ViewModel == null && Model.LoginWithLNURLAuthViewModel == null)
|
||||
{
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
}
|
||||
|
||||
<form method="post" asp-action="SetPassword">
|
||||
<div asp-validation-summary="All"></div>
|
||||
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
|
||||
<input asp-for="Code" type="hidden"/>
|
||||
<input asp-for="EmailSetInternally" type="hidden"/>
|
||||
@if (Model.EmailSetInternally)
|
||||
|
|
|
@ -30,11 +30,6 @@
|
|||
<partial name="LayoutHead"/>
|
||||
<link href="~/vendor/bootstrap-vue/bootstrap-vue.min.css" asp-append-version="true" rel="stylesheet"/>
|
||||
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
|
||||
<style>
|
||||
.no-marker > ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-vh-100">
|
||||
<div id="app" class="d-flex flex-column min-vh-100 pb-l">
|
||||
|
|
|
@ -36,8 +36,6 @@
|
|||
<span asp-validation-for="IsAdmin" class="text-danger"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@if (ViewData["AllowRequestEmailConfirmation"] is true)
|
||||
{
|
||||
<div class="form-group form-check">
|
||||
|
@ -46,8 +44,16 @@
|
|||
<span asp-validation-for="EmailConfirmed" class="text-danger"></span>
|
||||
</div>
|
||||
}
|
||||
@if (ViewData["AllowRequestApproval"] is true)
|
||||
{
|
||||
<div class="form-group form-check">
|
||||
<input asp-for="Approved" type="checkbox" class="form-check-input"/>
|
||||
<label asp-for="Approved" class="form-check-label"></label>
|
||||
<span asp-validation-for="Approved" class="text-danger"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button id="Save" type="submit" class="btn btn-primary mt-2" name="command" value="Save">Create account</button>
|
||||
<button id="Save" type="submit" class="btn btn-primary mt-2" name="command" value="Save">Create account</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,16 +3,12 @@
|
|||
@{
|
||||
ViewData.SetActivePage(ServerNavPages.Users);
|
||||
var nextUserEmailSortOrder = (string)ViewData["NextUserEmailSortOrder"];
|
||||
String userEmailSortOrder = null;
|
||||
switch (nextUserEmailSortOrder)
|
||||
var userEmailSortOrder = nextUserEmailSortOrder switch
|
||||
{
|
||||
case "asc":
|
||||
userEmailSortOrder = "desc";
|
||||
break;
|
||||
case "desc":
|
||||
userEmailSortOrder = "asc";
|
||||
break;
|
||||
}
|
||||
"asc" => "desc",
|
||||
"desc" => "asc",
|
||||
_ => null
|
||||
};
|
||||
|
||||
var sortIconClass = "fa-sort";
|
||||
if (userEmailSortOrder != null)
|
||||
|
@ -20,8 +16,8 @@
|
|||
sortIconClass = $"fa-sort-alpha-{userEmailSortOrder}";
|
||||
}
|
||||
|
||||
var sortByDesc = "Sort by descending...";
|
||||
var sortByAsc = "Sort by ascending...";
|
||||
const string sortByDesc = "Sort by descending...";
|
||||
const string sortByAsc = "Sort by ascending...";
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
|
@ -31,14 +27,8 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<form asp-action="ListUsers" asp-route-sortOrder="@(userEmailSortOrder)" style="max-width:640px">
|
||||
<div class="input-group">
|
||||
<input asp-for="SearchTerm" class="form-control" placeholder="Search by email..." />
|
||||
<button type="submit" class="btn btn-secondary" title="Search by email">
|
||||
<span class="fa fa-search"></span> Search
|
||||
</button>
|
||||
</div>
|
||||
<span asp-validation-for="SearchTerm" class="text-danger"></span>
|
||||
<form asp-action="ListUsers" asp-route-sortOrder="@(userEmailSortOrder)" style="max-width:640px" method="get">
|
||||
<input asp-for="SearchTerm" class="form-control" placeholder="Search by email..." />
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
|
@ -53,58 +43,52 @@
|
|||
title="@(nextUserEmailSortOrder == "desc" ? sortByAsc : sortByDesc)"
|
||||
>
|
||||
Email
|
||||
<span class="fa @(sortIconClass)" />
|
||||
<span class="fa @(sortIconClass)"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th >Created</th>
|
||||
<th class="text-center">Verified</th>
|
||||
<th class="text-center">Enabled</th>
|
||||
<th class="text-end">Actions</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
<th class="actions-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="UsersList">
|
||||
@foreach (var user in Model.Users)
|
||||
{
|
||||
var status = user switch
|
||||
{
|
||||
{ Disabled: true } => ("Disabled", "danger"),
|
||||
{ Approved: false } => ("Pending Approval", "warning"),
|
||||
{ EmailConfirmed: false } => ("Pending Email Verification", "warning"),
|
||||
_ => ("Active", "success")
|
||||
};
|
||||
<tr>
|
||||
<td class="d-flex align-items-center">
|
||||
<span class="me-2">@user.Email</span>
|
||||
<td class="d-flex align-items-center gap-2">
|
||||
<span class="user-email">@user.Email</span>
|
||||
@foreach (var role in user.Roles)
|
||||
{
|
||||
<span class="badge bg-info">@Model.Roles[role]</span>
|
||||
}
|
||||
</td>
|
||||
<td>@user.Created?.ToBrowserDate()</td>
|
||||
<td class="text-center">
|
||||
@if (user.Verified)
|
||||
{
|
||||
<span class="text-success fa fa-check"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-danger fa fa-times"></span>
|
||||
}
|
||||
<td>
|
||||
<span class="user-status badge bg-@status.Item2">@status.Item1</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (!user.Disabled)
|
||||
{
|
||||
<span class="text-success fa fa-check" title="User is enabled"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-danger fa fa-times" title="User is disabled"></span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (!user.Verified && !user.Disabled) {
|
||||
<a asp-action="SendVerificationEmail" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="This will send a verification email to <strong>@Html.Encode(user.Email)</strong>.">Resend verification email</a>
|
||||
<span>-</span>
|
||||
}
|
||||
<a asp-action="User" asp-route-userId="@user.Id">Edit</a> <span> - </span> <a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a>
|
||||
- <a asp-action="ToggleUser"
|
||||
asp-route-enable="@user.Disabled"
|
||||
asp-route-userId="@user.Id">
|
||||
@(user.Disabled ? "Enable" : "Disable")
|
||||
</a>
|
||||
<td class="actions-col">
|
||||
<div class="d-inline-flex align-items-center gap-3">
|
||||
@if (user is { EmailConfirmed: false, Disabled: false }) {
|
||||
<a asp-action="SendVerificationEmail" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="Send verification email" data-description="This will send a verification email to <strong>@Html.Encode(user.Email)</strong>." data-confirm="Send">Resend email</a>
|
||||
}
|
||||
else if (user is { Approved: false, Disabled: false })
|
||||
{
|
||||
<a asp-action="ApproveUser" asp-route-userId="@user.Id" asp-route-approved="true" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="Approve user" data-description="This will approve the user <strong>@Html.Encode(user.Email)</strong>." data-confirm="Approve">Approve</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-action="ToggleUser" asp-route-userId="@user.Id" asp-route-enable="@user.Disabled">@(user.Disabled ? "Enable" : "Disable")</a>
|
||||
}
|
||||
<a asp-action="User" asp-route-userId="@user.Id" class="user-edit">Edit</a>
|
||||
<a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
|
|
@ -11,15 +11,10 @@
|
|||
|
||||
@section PageHeadContent {
|
||||
<style>
|
||||
#AllowLightningInternalNodeForAll ~ .info-note,
|
||||
#AllowHotWalletRPCImportForAll ~ .info-note,
|
||||
#AllowHotWalletForAll ~ .info-note,
|
||||
#DisableNonAdminCreateUserApi:checked ~ .info-note,
|
||||
#LockSubscription:checked ~ .info-note { display: none; }
|
||||
|
||||
#AllowLightningInternalNodeForAll:checked ~ .info-note,
|
||||
#AllowHotWalletRPCImportForAll:checked ~ .info-note,
|
||||
#AllowHotWalletForAll:checked ~ .info-note { display: inline-flex; }
|
||||
input[type="checkbox"] ~ .info-note,
|
||||
input[type="checkbox"] ~ .subsettings { display: none; }
|
||||
input[type="checkbox"]:checked ~ .info-note { display: flex; max-width: 44em; }
|
||||
input[type="checkbox"]:checked ~ .subsettings { display: block; }
|
||||
</style>
|
||||
}
|
||||
|
||||
|
@ -31,10 +26,59 @@
|
|||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xl-8 col-xxl-constrain">
|
||||
<div class="col-xl-10 col-xxl-constrain">
|
||||
<form method="post" class="d-flex flex-column">
|
||||
<div class="form-group mb-5">
|
||||
<h4 class="mb-3">Existing User Settings</h4>
|
||||
<h4 class="mb-3">Registration Settings</h4>
|
||||
<div class="form-check my-3">
|
||||
<input asp-for="EnableRegistration" type="checkbox" class="form-check-input"/>
|
||||
<label asp-for="EnableRegistration" class="form-check-label"></label>
|
||||
<span asp-validation-for="EnableRegistration" class="text-danger"></span>
|
||||
<div class="info-note mt-2 text-warning" role="alert">
|
||||
<vc:icon symbol="warning"/>
|
||||
Caution: Enabling public user registration means anyone can register to your server and may expose your BTCPay Server instance to potential security risks from unknown users.
|
||||
</div>
|
||||
<div class="subsettings">
|
||||
<div class="form-check my-3">
|
||||
@{
|
||||
var emailSettings = (await _SettingsRepository.GetSettingAsync<EmailSettings>()) ?? new EmailSettings();
|
||||
/* The "|| Model.RequiresConfirmedEmail" check is for the case when a user had checked
|
||||
the checkbox without first configuring the e-mail settings so that they can uncheck it. */
|
||||
var isEmailConfigured = emailSettings.IsComplete() || Model.RequiresConfirmedEmail;
|
||||
}
|
||||
<input asp-for="RequiresConfirmedEmail" type="checkbox" class="form-check-input" disabled="@(isEmailConfigured ? null : "disabled")"/>
|
||||
<label asp-for="RequiresConfirmedEmail" class="form-check-label"></label>
|
||||
<a href="https://docs.btcpayserver.org/FAQ/ServerSettings/#how-to-allow-registration-on-my-btcpay-server" target="_blank" rel="noreferrer noopener">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
<span asp-validation-for="RequiresConfirmedEmail" class="text-danger"></span>
|
||||
@if (!isEmailConfigured)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<span class="text-secondary">Your email server has not been configured. <a asp-controller="UIServer" asp-action="Emails">Please configure it first.</a></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-check my-3">
|
||||
<input asp-for="RequiresUserApproval" type="checkbox" class="form-check-input"/>
|
||||
<label asp-for="RequiresUserApproval" class="form-check-label"></label>
|
||||
<span asp-validation-for="RequiresUserApproval" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-check my-3">
|
||||
<input asp-for="EnableNonAdminCreateUserApi" type="checkbox" class="form-check-input"/>
|
||||
<label asp-for="EnableNonAdminCreateUserApi" class="form-check-label"></label>
|
||||
<span asp-validation-for="EnableNonAdminCreateUserApi" class="text-danger"></span>
|
||||
<div class="info-note mt-2 text-warning" role="alert">
|
||||
<vc:icon symbol="warning"/>
|
||||
Caution: Allowing non-admins to have access to API endpoints may expose your BTCPay Server instance to potential security risks from unknown users.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-5">
|
||||
<h4 class="mb-3">User Settings</h4>
|
||||
<div class="form-check my-3">
|
||||
<input asp-for="AllowLightningInternalNodeForAll" type="checkbox" class="form-check-input"/>
|
||||
<label asp-for="AllowLightningInternalNodeForAll" class="form-check-label"></label>
|
||||
|
@ -70,49 +114,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-5">
|
||||
<h4 class="mb-3">New User Settings</h4>
|
||||
<div class="form-check my-3">
|
||||
@{
|
||||
var emailSettings = (await _SettingsRepository.GetSettingAsync<EmailSettings>()) ?? new EmailSettings();
|
||||
/* The "|| Model.RequiresConfirmedEmail" check is for the case when a user had checked
|
||||
the checkbox without first configuring the e-mail settings so that they can uncheck it. */
|
||||
var isEmailConfigured = emailSettings.IsComplete() || Model.RequiresConfirmedEmail;
|
||||
}
|
||||
<input asp-for="RequiresConfirmedEmail" type="checkbox" class="form-check-input" disabled="@(isEmailConfigured ? null : "disabled")"/>
|
||||
<label asp-for="RequiresConfirmedEmail" class="form-check-label"></label>
|
||||
<a href="https://docs.btcpayserver.org/FAQ/ServerSettings/#how-to-allow-registration-on-my-btcpay-server" target="_blank" rel="noreferrer noopener">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
<span asp-validation-for="RequiresConfirmedEmail" class="text-danger"></span>
|
||||
@if (!isEmailConfigured)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<span class="text-secondary">Your email server has not been configured. <a asp-controller="UIServer" asp-action="Emails">Please configure it first.</a></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-check my-3">
|
||||
<input asp-for="LockSubscription" type="checkbox" class="form-check-input"/>
|
||||
<label asp-for="LockSubscription" class="form-check-label"></label>
|
||||
<span asp-validation-for="LockSubscription" class="text-danger"></span>
|
||||
<div class="info-note mt-2 text-warning" role="alert">
|
||||
<vc:icon symbol="warning"/>
|
||||
Caution: Enabling public user registration means anyone can register to your server and may expose your BTCPay Server instance to potential security risks from unknown users.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check my-3">
|
||||
<input asp-for="DisableNonAdminCreateUserApi" type="checkbox" class="form-check-input"/>
|
||||
<label asp-for="DisableNonAdminCreateUserApi" class="form-check-label"></label>
|
||||
<span asp-validation-for="DisableNonAdminCreateUserApi" class="text-danger"></span>
|
||||
<div class="info-note mt-2 text-warning" role="alert">
|
||||
<vc:icon symbol="warning"/>
|
||||
Caution: Allowing non-admins to have access to API endpoints may expose your BTCPay Server instance to potential security risks from unknown users.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-5">
|
||||
<h4 class="mb-3">Email Settings</h4>
|
||||
<div class="form-check my-3">
|
||||
|
@ -181,7 +182,7 @@
|
|||
<h4 class="mt-5">Customization Settings</h4>
|
||||
<div class="form-group mb-5">
|
||||
<label asp-for="RootAppId" class="form-label"></label>
|
||||
<select asp-for="RootAppId" asp-items="@(new SelectList(ViewBag.AppsList, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.RootAppId))" class="form-select w-auto"></select>
|
||||
<select asp-for="RootAppId" asp-items="@(new SelectList(ViewBag.AppsList, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.RootAppId))" class="form-select"></select>
|
||||
@if (!Model.DomainToAppMapping.Any())
|
||||
{
|
||||
<button id="AddDomainButton" type="submit" name="command" value="add-domain" class="btn btn-link px-0">Map specific domains to specific apps</button>
|
||||
|
@ -214,7 +215,7 @@
|
|||
<label asp-for="DomainToAppMapping[index].AppId" class="form-label"></label>
|
||||
<select asp-for="DomainToAppMapping[index].AppId"
|
||||
asp-items="@(new SelectList(ViewBag.AppsList, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.DomainToAppMapping[index].AppId))"
|
||||
class="form-select w-auto">
|
||||
class="form-select">
|
||||
</select>
|
||||
<span asp-validation-for="DomainToAppMapping[index].AppId" class="text-danger"></span>
|
||||
</div>
|
||||
|
|
|
@ -5,14 +5,26 @@
|
|||
|
||||
<h3 class="mb-4">@ViewData["Title"]</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<form method="post">
|
||||
<div class="form-group form-check mb-4">
|
||||
<input asp-for="IsAdmin" type="checkbox" class="form-check-input" />
|
||||
<label asp-for="IsAdmin" class="form-check-label">Is admin</label>
|
||||
</div>
|
||||
<button name="command" type="submit" class="btn btn-primary" value="Save">Save</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<div class="form-check my-3">
|
||||
<input asp-for="IsAdmin" type="checkbox" class="form-check-input" />
|
||||
<label asp-for="IsAdmin" class="form-check-label">User is admin</label>
|
||||
</div>
|
||||
</div>
|
||||
@if (Model.Approved.HasValue)
|
||||
{
|
||||
<div class="form-check my-3">
|
||||
<input id="Approved" name="Approved" type="checkbox" value="true" class="form-check-input" @(Model.Approved.Value ? "checked" : "") />
|
||||
<label for="Approved" class="form-check-label">User is approved</label>
|
||||
</div>
|
||||
<input name="Approved" type="hidden" value="false">
|
||||
}
|
||||
@if (Model.EmailConfirmed.HasValue)
|
||||
{
|
||||
<div class="form-check my-3">
|
||||
<input id="EmailConfirmed" name="EmailConfirmed" value="true" type="checkbox" class="form-check-input" @(Model.EmailConfirmed.Value ? "checked" : "") />
|
||||
<label for="EmailConfirmed" class="form-check-label">Email address is confirmed</label>
|
||||
</div>
|
||||
<input name="EmailConfirmed" type="hidden" value="false">
|
||||
}
|
||||
<button name="command" type="submit" class="btn btn-primary mt-3" value="Save" id="SaveUser">Save</button>
|
||||
</form>
|
||||
|
|
|
@ -42,6 +42,11 @@
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.no-marker > ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* General and site-wide Bootstrap modifications */
|
||||
p {
|
||||
margin-bottom: 1.5rem;
|
||||
|
|
|
@ -255,7 +255,7 @@
|
|||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Toggle user",
|
||||
"summary": "Toggle user lock out",
|
||||
"description": "Lock or unlock a user.\n\nMust be an admin to perform this operation.\n\nAttempting to lock the only admin user will not succeed.",
|
||||
"parameters": [
|
||||
{
|
||||
|
@ -283,10 +283,63 @@
|
|||
"description": "User has been successfully toggled"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing authorization for deleting the user"
|
||||
"description": "Missing authorization for locking the user"
|
||||
},
|
||||
"403": {
|
||||
"description": "Authorized but forbidden to disable the user. Can happen if you attempt to disable the only admin user."
|
||||
"description": "Authorized but forbidden to lock the user. Can happen if you attempt to disable the only admin user."
|
||||
},
|
||||
"404": {
|
||||
"description": "User with provided ID was not found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"API_Key": [
|
||||
"btcpay.user.canmodifyserversettings"
|
||||
],
|
||||
"Basic": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/{idOrEmail}/approve": {
|
||||
"post": {
|
||||
"operationId": "Users_ToggleUserApproval",
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Toggle user approval",
|
||||
"description": "Approve or unapprove a user.\n\nMust be an admin to perform this operation.\n\nAttempting to (un)approve a user for which this requirement does not exist will not succeed.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "idOrEmail",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "The ID of the user to be un/approved",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"x-name": "request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApproveUserRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User has been successfully toggled"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing authorization for approving the user"
|
||||
},
|
||||
"403": {
|
||||
"description": "Authorized but forbidden to approve the user. Can happen if you attempt to set the status of a user that does not have the approval requirement."
|
||||
},
|
||||
"404": {
|
||||
"description": "User with provided ID was not found"
|
||||
|
@ -325,7 +378,15 @@
|
|||
},
|
||||
"requiresEmailConfirmation": {
|
||||
"type": "boolean",
|
||||
"description": "True if the email requires email confirmation to log in"
|
||||
"description": "True if the email requires confirmation to log in"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean",
|
||||
"description": "True if an admin has approved the user"
|
||||
},
|
||||
"requiresApproval": {
|
||||
"type": "boolean",
|
||||
"description": "True if the instance requires approval to log in"
|
||||
},
|
||||
"created": {
|
||||
"nullable": true,
|
||||
|
@ -355,6 +416,16 @@
|
|||
"description": "Whether to lock or unlock the user"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApproveUserRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"approved": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to approve or unapprove the user"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Add table
Reference in a new issue