mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 06:24:24 +01:00
Allow Users to be disabled/enabled (#3639)
* Allow Users to be disabled/enabled * rebrand to locked for api * Update BTCPayServer/Views/UIAccount/Lockout.cshtml Co-authored-by: d11n <mail@dennisreimann.de> * fix docker compose and an uneeded check in api handler * fix * Add enabled user test Co-authored-by: d11n <mail@dennisreimann.de> Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
This commit is contained in:
@@ -33,6 +33,13 @@ namespace BTCPayServer.Client
|
|||||||
return await HandleResponse<ApplicationUserData>(response);
|
return await HandleResponse<ApplicationUserData>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public virtual async Task LockUser(string idOrEmail, bool locked, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/{idOrEmail}/lock", null,
|
||||||
|
new LockUserRequest() {Locked = locked}, HttpMethod.Post), token);
|
||||||
|
await HandleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
public virtual async Task<ApplicationUserData[]> GetUsers( CancellationToken token = default)
|
public virtual async Task<ApplicationUserData[]> GetUsers( CancellationToken token = default)
|
||||||
{
|
{
|
||||||
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token);
|
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token);
|
||||||
|
|||||||
@@ -35,5 +35,7 @@ namespace BTCPayServer.Client.Models
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||||
public DateTimeOffset? Created { get; set; }
|
public DateTimeOffset? Created { get; set; }
|
||||||
|
|
||||||
|
public bool Disabled { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
BTCPayServer.Client/Models/LockUserRequest.cs
Normal file
6
BTCPayServer.Client/Models/LockUserRequest.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace BTCPayServer.Client;
|
||||||
|
|
||||||
|
public class LockUserRequest
|
||||||
|
{
|
||||||
|
public bool Locked { get; set; }
|
||||||
|
}
|
||||||
@@ -2341,6 +2341,45 @@ namespace BTCPayServer.Tests
|
|||||||
new SendEmailRequest() { Body = "lol", Subject = "subj", Email = "sdasdas" });
|
new SendEmailRequest() { Body = "lol", Subject = "subj", Email = "sdasdas" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(Timeout = 60 * 2 * 1000)]
|
||||||
|
[Trait("Integration", "Integration")]
|
||||||
|
public async Task DisabledEnabledUserTests()
|
||||||
|
{
|
||||||
|
using var tester = CreateServerTester();
|
||||||
|
await tester.StartAsync();
|
||||||
|
var admin = tester.NewAccount();
|
||||||
|
await admin.GrantAccessAsync(true);
|
||||||
|
var adminClient = await admin.CreateClient(Policies.Unrestricted);
|
||||||
|
|
||||||
|
var newUser = tester.NewAccount();
|
||||||
|
await newUser.GrantAccessAsync();
|
||||||
|
var newUserClient = await newUser.CreateClient(Policies.Unrestricted);
|
||||||
|
Assert.False((await newUserClient.GetCurrentUser()).Disabled);
|
||||||
|
|
||||||
|
await adminClient.LockUser(newUser.UserId, true, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True((await adminClient.GetUserByIdOrEmail(newUser.UserId)).Disabled);
|
||||||
|
await AssertAPIError("unauthenticated",async () =>
|
||||||
|
{
|
||||||
|
await newUserClient.GetCurrentUser();
|
||||||
|
});
|
||||||
|
var newUserBasicClient = new BTCPayServerClient(newUserClient.Host, newUser.RegisterDetails.Email,
|
||||||
|
newUser.RegisterDetails.Password);
|
||||||
|
await AssertAPIError("unauthenticated",async () =>
|
||||||
|
{
|
||||||
|
await newUserBasicClient.GetCurrentUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
await adminClient.LockUser(newUser.UserId, false, CancellationToken.None);
|
||||||
|
Assert.False((await adminClient.GetUserByIdOrEmail(newUser.UserId)).Disabled);
|
||||||
|
await newUserClient.GetCurrentUser();
|
||||||
|
await newUserBasicClient.GetCurrentUser();
|
||||||
|
// Twice for good measure
|
||||||
|
await adminClient.LockUser(newUser.UserId, false, CancellationToken.None);
|
||||||
|
Assert.False((await adminClient.GetUserByIdOrEmail(newUser.UserId)).Disabled);
|
||||||
|
await newUserClient.GetCurrentUser();
|
||||||
|
await newUserBasicClient.GetCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(Timeout = 60 * 2 * 1000)]
|
[Fact(Timeout = 60 * 2 * 1000)]
|
||||||
[Trait("Integration", "Integration")]
|
[Trait("Integration", "Integration")]
|
||||||
|
|||||||
@@ -73,6 +73,19 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
}
|
}
|
||||||
return UserNotFound();
|
return UserNotFound();
|
||||||
}
|
}
|
||||||
|
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
|
[HttpPost("~/api/v1/users/{idOrEmail}/lock")]
|
||||||
|
public async Task<IActionResult> LockUser(string idOrEmail, LockUserRequest request )
|
||||||
|
{
|
||||||
|
var user = (await _userManager.FindByIdAsync(idOrEmail) ) ?? await _userManager.FindByEmailAsync(idOrEmail);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return UserNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _userService.ToggleUser(user.Id, request.Locked ? DateTimeOffset.MaxValue : null);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
[Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
[Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
[HttpGet("~/api/v1/users/")]
|
[HttpGet("~/api/v1/users/")]
|
||||||
@@ -219,7 +232,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User shouldn't be deleted if it's the only admin
|
// User shouldn't be deleted if it's the only admin
|
||||||
if (await IsUserTheOnlyOneAdmin(user))
|
if (await _userService.IsUserTheOnlyOneAdmin(user))
|
||||||
{
|
{
|
||||||
return Forbid(AuthenticationSchemes.GreenfieldBasic);
|
return Forbid(AuthenticationSchemes.GreenfieldBasic);
|
||||||
}
|
}
|
||||||
@@ -236,21 +249,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
return UserService.FromModel(data, roles);
|
return UserService.FromModel(data, roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> IsUserTheOnlyOneAdmin()
|
|
||||||
{
|
|
||||||
return await IsUserTheOnlyOneAdmin(await _userManager.GetUserAsync(User));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> IsUserTheOnlyOneAdmin(ApplicationUser user)
|
|
||||||
{
|
|
||||||
var isUserAdmin = await _userService.IsAdminUser(user);
|
|
||||||
if (!isUserAdmin)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Count == 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IActionResult UserNotFound()
|
private IActionResult UserNotFound()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
Host = new HostString("dummy.com"),
|
Host = new HostString("dummy.com"),
|
||||||
Path = new PathString(),
|
Path = new PathString(),
|
||||||
PathBase = new PathString(),
|
PathBase = new PathString(),
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -533,13 +532,6 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
await _storeLightningNodeApiController.GetInvoice(cryptoCode, invoiceId, token));
|
await _storeLightningNodeApiController.GetInvoice(cryptoCode, invoiceId, token));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<LightningPaymentData> GetLightningPayment(string storeId, string cryptoCode,
|
|
||||||
string paymentHash, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
return GetFromActionResult<LightningPaymentData>(
|
|
||||||
await _storeLightningNodeApiController.GetPayment(cryptoCode, paymentHash, token));
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task<LightningInvoiceData> CreateLightningInvoice(string storeId, string cryptoCode,
|
public override async Task<LightningInvoiceData> CreateLightningInvoice(string storeId, string cryptoCode,
|
||||||
CreateLightningInvoiceRequest request, CancellationToken token = default)
|
CreateLightningInvoiceRequest request, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
@@ -594,13 +586,6 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
await _lightningNodeApiController.GetInvoice(cryptoCode, invoiceId, token));
|
await _lightningNodeApiController.GetInvoice(cryptoCode, invoiceId, token));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<LightningPaymentData> GetLightningPayment(string cryptoCode,
|
|
||||||
string paymentHash, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
return GetFromActionResult<LightningPaymentData>(
|
|
||||||
await _lightningNodeApiController.GetPayment(cryptoCode, paymentHash, token));
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task<LightningInvoiceData> CreateLightningInvoice(string cryptoCode,
|
public override async Task<LightningInvoiceData> CreateLightningInvoice(string cryptoCode,
|
||||||
CreateLightningInvoiceRequest request,
|
CreateLightningInvoiceRequest request,
|
||||||
CancellationToken token = default)
|
CancellationToken token = default)
|
||||||
@@ -1135,7 +1120,29 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
{
|
{
|
||||||
return GetFromActionResult<ApplicationUserData>(await _usersController.GetUser(idOrEmail));
|
return GetFromActionResult<ApplicationUserData>(await _usersController.GetUser(idOrEmail));
|
||||||
}
|
}
|
||||||
|
public override async Task LockUser(string idOrEmail, bool disabled, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
HandleActionResult(await _usersController.LockUser(idOrEmail, new LockUserRequest()
|
||||||
|
{
|
||||||
|
Locked = disabled
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<OnChainWalletTransactionData> PatchOnChainWalletTransaction(string storeId, string cryptoCode, string transactionId,
|
||||||
|
PatchOnChainTransactionRequest request, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return GetFromActionResult<OnChainWalletTransactionData>(await _storeOnChainWalletsController.PatchOnChainWalletTransaction(storeId, cryptoCode, transactionId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<LightningPaymentData> GetLightningPayment(string cryptoCode, string paymentHash, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return GetFromActionResult<LightningPaymentData>(await _lightningNodeApiController.GetPayment(cryptoCode, paymentHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<LightningPaymentData> GetLightningPayment(string storeId, string cryptoCode, string paymentHash, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return GetFromActionResult<LightningPaymentData>(await _storeLightningNodeApiController.GetPayment(cryptoCode, paymentHash));
|
||||||
|
}
|
||||||
public override async Task<PayoutData> CreatePayout(string storeId, CreatePayoutThroughStoreRequest payoutRequest,
|
public override async Task<PayoutData> CreatePayout(string storeId, CreatePayoutThroughStoreRequest payoutRequest,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ namespace BTCPayServer.Controllers
|
|||||||
if (result.IsLockedOut)
|
if (result.IsLockedOut)
|
||||||
{
|
{
|
||||||
_logger.LogWarning($"User '{user.Id}' account locked out.");
|
_logger.LogWarning($"User '{user.Id}' account locked out.");
|
||||||
return RedirectToAction(nameof(Lockout));
|
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -428,7 +428,7 @@ namespace BTCPayServer.Controllers
|
|||||||
else if (result.IsLockedOut)
|
else 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));
|
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -497,7 +497,8 @@ namespace BTCPayServer.Controllers
|
|||||||
if (result.IsLockedOut)
|
if (result.IsLockedOut)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
|
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
|
||||||
return RedirectToAction(nameof(Lockout));
|
|
||||||
|
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -509,9 +510,9 @@ namespace BTCPayServer.Controllers
|
|||||||
|
|
||||||
[HttpGet("/login/lockout")]
|
[HttpGet("/login/lockout")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public IActionResult Lockout()
|
public IActionResult Lockout(DateTimeOffset? lockoutEnd)
|
||||||
{
|
{
|
||||||
return View();
|
return View(lockoutEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("/register")]
|
[HttpGet("/register")]
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ namespace BTCPayServer.Controllers
|
|||||||
Id = u.Id,
|
Id = u.Id,
|
||||||
Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation,
|
Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation,
|
||||||
Created = u.Created,
|
Created = u.Created,
|
||||||
Roles = u.UserRoles.Select(role => role.RoleId)
|
Roles = u.UserRoles.Select(role => role.RoleId),
|
||||||
|
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
model.Total = await usersQuery.CountAsync();
|
model.Total = await usersQuery.CountAsync();
|
||||||
@@ -217,12 +218,11 @@ namespace BTCPayServer.Controllers
|
|||||||
var roles = await _UserManager.GetRolesAsync(user);
|
var roles = await _UserManager.GetRolesAsync(user);
|
||||||
if (_userService.IsRoleAdmin(roles))
|
if (_userService.IsRoleAdmin(roles))
|
||||||
{
|
{
|
||||||
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
|
if (await _userService.IsUserTheOnlyOneAdmin(user))
|
||||||
if (admins.Count == 1)
|
|
||||||
{
|
{
|
||||||
// return
|
// return
|
||||||
return View("Confirm", new ConfirmModel("Delete admin",
|
return View("Confirm", new ConfirmModel("Delete admin",
|
||||||
$"Unable to proceed: As the user <strong>{user.Email}</strong> is the last admin, it cannot be removed."));
|
$"Unable to proceed: As the user <strong>{user.Email}</strong> is the last enabled admin, it cannot be removed."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return View("Confirm", new ConfirmModel("Delete admin",
|
return View("Confirm", new ConfirmModel("Delete admin",
|
||||||
@@ -245,6 +245,41 @@ namespace BTCPayServer.Controllers
|
|||||||
TempData[WellKnownTempData.SuccessMessage] = "User deleted";
|
TempData[WellKnownTempData.SuccessMessage] = "User deleted";
|
||||||
return RedirectToAction(nameof(ListUsers));
|
return RedirectToAction(nameof(ListUsers));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[HttpGet("server/users/{userId}/toggle")]
|
||||||
|
public async Task<IActionResult> ToggleUser(string userId, bool enable)
|
||||||
|
{
|
||||||
|
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
if (!enable && await _userService.IsUserTheOnlyOneAdmin(user))
|
||||||
|
{
|
||||||
|
return View("Confirm", new ConfirmModel("Disable admin",
|
||||||
|
$"Unable to proceed: As the user <strong>{user.Email}</strong> is the last enabled admin, it cannot be disabled."));
|
||||||
|
}
|
||||||
|
return View("Confirm", new ConfirmModel($"{(enable? "Enable" : "Disable")} user", $"The user <strong>{user.Email}</strong> will be {(enable? "enabled" : "disabled")}. Are you sure?", (enable? "Enable" : "Disable")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("server/users/{userId}/toggle")]
|
||||||
|
public async Task<IActionResult> ToggleUserPost(string userId, bool enable)
|
||||||
|
{
|
||||||
|
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
return NotFound();
|
||||||
|
if (!enable && await _userService.IsUserTheOnlyOneAdmin(user))
|
||||||
|
{
|
||||||
|
TempData[WellKnownTempData.SuccessMessage] = $"User was the last enabled admin and could not be disabled.";
|
||||||
|
return RedirectToAction(nameof(ListUsers));
|
||||||
|
}
|
||||||
|
await _userService.ToggleUser(userId, enable? null: DateTimeOffset.MaxValue);
|
||||||
|
|
||||||
|
TempData[WellKnownTempData.SuccessMessage] = $"User {(enable? "enabled": "disabled")}";
|
||||||
|
return RedirectToAction(nameof(ListUsers));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RegisterFromAdminViewModel
|
public class RegisterFromAdminViewModel
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ namespace BTCPayServer.Models.ServerViewModels
|
|||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public string Email { get; set; }
|
public string Email { get; set; }
|
||||||
public bool Verified { get; set; }
|
public bool Verified { get; set; }
|
||||||
|
public bool Disabled { get; set; }
|
||||||
public bool IsAdmin { get; set; }
|
public bool IsAdmin { get; set; }
|
||||||
public DateTimeOffset? Created { get; set; }
|
public DateTimeOffset? Created { get; set; }
|
||||||
public IEnumerable<string> Roles { get; set; }
|
public IEnumerable<string> Roles { get; set; }
|
||||||
|
|||||||
@@ -45,14 +45,14 @@ public class LightningAutomatedPayoutSenderFactory : IPayoutProcessorFactory
|
|||||||
new PaymentMethodId(network.CryptoCode, LightningPaymentType.Instance));
|
new PaymentMethodId(network.CryptoCode, LightningPaymentType.Instance));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
|
public Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
|
||||||
{
|
{
|
||||||
if (settings.Processor != Processor)
|
if (settings.Processor != Processor)
|
||||||
{
|
{
|
||||||
throw new NotSupportedException("This processor cannot handle the provided requirements");
|
throw new NotSupportedException("This processor cannot handle the provided requirements");
|
||||||
}
|
}
|
||||||
|
|
||||||
return ActivatorUtilities.CreateInstance<LightningAutomatedPayoutProcessor>(_serviceProvider, settings);
|
return Task.FromResult<IHostedService>(ActivatorUtilities.CreateInstance<LightningAutomatedPayoutProcessor>(_serviceProvider, settings));
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ public class OnChainAutomatedPayoutSenderFactory : EventHostedServiceBase, IPayo
|
|||||||
new PaymentMethodId(network.CryptoCode, BitcoinPaymentType.Instance));
|
new PaymentMethodId(network.CryptoCode, BitcoinPaymentType.Instance));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
|
public Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
|
||||||
{
|
{
|
||||||
if (settings.Processor != Processor)
|
if (settings.Processor != Processor)
|
||||||
{
|
{
|
||||||
throw new NotSupportedException("This processor cannot handle the provided requirements");
|
throw new NotSupportedException("This processor cannot handle the provided requirements");
|
||||||
}
|
}
|
||||||
|
|
||||||
return ActivatorUtilities.CreateInstance<OnChainAutomatedPayoutProcessor>(_serviceProvider, settings);
|
return Task.FromResult<IHostedService>(ActivatorUtilities.CreateInstance<OnChainAutomatedPayoutProcessor>(_serviceProvider, settings));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ namespace BTCPayServer.Security.Greenfield
|
|||||||
|
|
||||||
var key = await _apiKeyRepository.GetKey(apiKey, true);
|
var key = await _apiKeyRepository.GetKey(apiKey, true);
|
||||||
|
|
||||||
if (key == null)
|
if (key == null || await _userManager.IsLockedOutAsync(key.User))
|
||||||
{
|
{
|
||||||
return AuthenticateResult.Fail("ApiKey authentication failed");
|
return AuthenticateResult.Fail("ApiKey authentication failed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,37 +7,35 @@ using BTCPayServer.Client.Models;
|
|||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Services.Stores;
|
using BTCPayServer.Services.Stores;
|
||||||
using BTCPayServer.Storage.Services;
|
using BTCPayServer.Storage.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace BTCPayServer.Services
|
namespace BTCPayServer.Services
|
||||||
{
|
{
|
||||||
public class UserService
|
public class UserService
|
||||||
{
|
{
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly IAuthorizationService _authorizationService;
|
|
||||||
private readonly StoredFileRepository _storedFileRepository;
|
private readonly StoredFileRepository _storedFileRepository;
|
||||||
private readonly FileService _fileService;
|
private readonly FileService _fileService;
|
||||||
private readonly StoreRepository _storeRepository;
|
private readonly StoreRepository _storeRepository;
|
||||||
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
|
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
|
||||||
|
private readonly ILogger<UserService> _logger;
|
||||||
|
|
||||||
public UserService(
|
public UserService(
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
IAuthorizationService authorizationService,
|
|
||||||
StoredFileRepository storedFileRepository,
|
StoredFileRepository storedFileRepository,
|
||||||
FileService fileService,
|
FileService fileService,
|
||||||
StoreRepository storeRepository,
|
StoreRepository storeRepository,
|
||||||
ApplicationDbContextFactory applicationDbContextFactory
|
ApplicationDbContextFactory applicationDbContextFactory,
|
||||||
|
ILogger<UserService> logger)
|
||||||
)
|
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_authorizationService = authorizationService;
|
|
||||||
_storedFileRepository = storedFileRepository;
|
_storedFileRepository = storedFileRepository;
|
||||||
_fileService = fileService;
|
_fileService = fileService;
|
||||||
_storeRepository = storeRepository;
|
_storeRepository = storeRepository;
|
||||||
_applicationDbContextFactory = applicationDbContextFactory;
|
_applicationDbContextFactory = applicationDbContextFactory;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<ApplicationUserData>> GetUsersWithRoles()
|
public async Task<List<ApplicationUserData>> GetUsersWithRoles()
|
||||||
@@ -57,10 +55,40 @@ namespace BTCPayServer.Services
|
|||||||
EmailConfirmed = data.EmailConfirmed,
|
EmailConfirmed = data.EmailConfirmed,
|
||||||
RequiresEmailConfirmation = data.RequiresEmailConfirmation,
|
RequiresEmailConfirmation = data.RequiresEmailConfirmation,
|
||||||
Created = data.Created,
|
Created = data.Created,
|
||||||
Roles = roles
|
Roles = roles,
|
||||||
|
Disabled = data.LockoutEnabled && data.LockoutEnd is not null && DateTimeOffset.UtcNow < data.LockoutEnd.Value.UtcDateTime
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsDisabled(ApplicationUser user)
|
||||||
|
{
|
||||||
|
return user.LockoutEnabled && user.LockoutEnd is not null &&
|
||||||
|
DateTimeOffset.UtcNow < user.LockoutEnd.Value.UtcDateTime;
|
||||||
|
}
|
||||||
|
public async Task ToggleUser(string userId, DateTimeOffset? lockedOutDeadline)
|
||||||
|
{
|
||||||
|
var user = await _userManager.FindByIdAsync(userId);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lockedOutDeadline is not null)
|
||||||
|
{
|
||||||
|
await _userManager.SetLockoutEnabledAsync(user, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = await _userManager.SetLockoutEndDateAsync(user, lockedOutDeadline);
|
||||||
|
if (res.Succeeded)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"User {user.Id} is now {(lockedOutDeadline is null ? "unlocked" : "locked")}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError($"Failed to set lockout for user {user.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> IsAdminUser(string userId)
|
public async Task<bool> IsAdminUser(string userId)
|
||||||
{
|
{
|
||||||
return IsRoleAdmin(await _userManager.GetRolesAsync(new ApplicationUser() { Id = userId }));
|
return IsRoleAdmin(await _userManager.GetRolesAsync(new ApplicationUser() { Id = userId }));
|
||||||
@@ -89,5 +117,17 @@ namespace BTCPayServer.Services
|
|||||||
{
|
{
|
||||||
return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal);
|
return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<bool> IsUserTheOnlyOneAdmin(ApplicationUser user)
|
||||||
|
{
|
||||||
|
var isUserAdmin = await IsAdminUser(user);
|
||||||
|
if (!isUserAdmin)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Count(applicationUser => !IsDisabled(applicationUser)) == 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
@{
|
@using BTCPayServer.Abstractions.Extensions
|
||||||
ViewData["Title"] = "Locked out";
|
@model DateTimeOffset?
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Account disabled";
|
||||||
|
Layout = "_LayoutSignedOut";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -8,6 +11,17 @@
|
|||||||
<hr class="primary">
|
<hr class="primary">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-12 lead">
|
<div class="col-lg-12 lead">
|
||||||
|
@if (DateTimeOffset.MaxValue - Model.Value < TimeSpan.FromSeconds(1))
|
||||||
|
{
|
||||||
|
<p>Your account has been disabled. Please contact server administrator.</p>
|
||||||
|
}
|
||||||
|
else if(Model is null)
|
||||||
|
{
|
||||||
<p>This account has been locked out because of multiple invalid login attempts. Please try again later.</p>
|
<p>This account has been locked out because of multiple invalid login attempts. Please try again later.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p>This account has been locked out. Please try again <span data-timeago-unixms="@Model.Value.ToUnixTimeMilliseconds()">@Model.Value.ToTimeAgo()</span>.</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th >Created</th>
|
<th >Created</th>
|
||||||
<th>Verified</th>
|
<th class="text-center">Verified</th>
|
||||||
|
<th class="text-center">Enabled</th>
|
||||||
<th class="text-end">Actions</th>
|
<th class="text-end">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -83,8 +84,23 @@
|
|||||||
<span class="text-danger fa fa-times"></span>
|
<span class="text-danger fa fa-times"></span>
|
||||||
}
|
}
|
||||||
</td>
|
</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">
|
<td class="text-end">
|
||||||
<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="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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,58 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/users/{idOrEmail}/lock": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"summary": "Toggle user",
|
||||||
|
"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": [
|
||||||
|
{
|
||||||
|
"name": "userId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "The ID of the user to be un/locked",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"x-name": "request",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/LockUserRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "User has been successfully toggled"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing authorization for deleting the user"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authorized but forbidden to disable 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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -281,6 +333,16 @@
|
|||||||
"description": "The roles of the user"
|
"description": "The roles of the user"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"LockUserRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"locked": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to lock or unlock the user"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user