Skip to content

Commit

Permalink
Allow Users to be disabled/enabled (#3639)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people committed Apr 26, 2022
1 parent 261a3ec commit 273bc78
Show file tree
Hide file tree
Showing 16 changed files with 290 additions and 61 deletions.
7 changes: 7 additions & 0 deletions BTCPayServer.Client/BTCPayServerClient.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ public virtual async Task<ApplicationUserData> GetUserByIdOrEmail(string idOrEma
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)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token);
Expand Down
2 changes: 2 additions & 0 deletions BTCPayServer.Client/Models/ApplicationUserData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,7 @@ public class ApplicationUserData
/// </summary>
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? Created { get; set; }

public bool Disabled { get; set; }
}
}
6 changes: 6 additions & 0 deletions BTCPayServer.Client/Models/LockUserRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace BTCPayServer.Client;

public class LockUserRequest
{
public bool Locked { get; set; }
}
39 changes: 39 additions & 0 deletions BTCPayServer.Tests/GreenfieldAPITests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2341,6 +2341,45 @@ public async Task StoreEmailTests()
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)]
[Trait("Integration", "Integration")]
Expand Down
31 changes: 15 additions & 16 deletions BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,20 @@ public async Task<IActionResult> GetUser(string idOrEmail)
}
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)]
[HttpGet("~/api/v1/users/")]
public async Task<ActionResult<ApplicationUserData[]>> GetUsers()
Expand Down Expand Up @@ -219,7 +232,7 @@ public async Task<IActionResult> DeleteUser(string userId)
}

// User shouldn't be deleted if it's the only admin
if (await IsUserTheOnlyOneAdmin(user))
if (await _userService.IsUserTheOnlyOneAdmin(user))
{
return Forbid(AuthenticationSchemes.GreenfieldBasic);
}
Expand All @@ -236,21 +249,7 @@ private async Task<ApplicationUserData> FromModel(ApplicationUser data)
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()
{
Expand Down
43 changes: 25 additions & 18 deletions BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ public Task<BTCPayServerClient> Create(string userId, params string[] storeIds)
Host = new HostString("dummy.com"),
Path = new PathString(),
PathBase = new PathString(),

}
});
}
Expand Down Expand Up @@ -532,13 +531,6 @@ public override async Task<StoreWebhookData[]> GetWebhooks(string storeId, Cance
return GetFromActionResult<LightningInvoiceData>(
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,
CreateLightningInvoiceRequest request, CancellationToken token = default)
Expand Down Expand Up @@ -593,13 +585,6 @@ public override async Task<StoreWebhookData[]> GetWebhooks(string storeId, Cance
return GetFromActionResult<LightningInvoiceData>(
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,
CreateLightningInvoiceRequest request,
Expand All @@ -624,9 +609,9 @@ private void HandleActionResult(IActionResult result)
{
switch (result)
{
case UnprocessableEntityObjectResult { Value: List<GreenfieldValidationError> validationErrors }:
case UnprocessableEntityObjectResult {Value: List<GreenfieldValidationError> validationErrors}:
throw new GreenfieldValidationException(validationErrors.ToArray());
case BadRequestObjectResult { Value: GreenfieldAPIError error }:
case BadRequestObjectResult {Value: GreenfieldAPIError error}:
throw new GreenfieldAPIException(400, error);
case NotFoundResult _:
throw new GreenfieldAPIException(404, new GreenfieldAPIError("not-found", ""));
Expand Down Expand Up @@ -775,7 +760,7 @@ public override async Task RevokeAPIKey(string apikey, CancellationToken token =
{
return GetFromActionResult<NotificationData>(
await _notificationsController.UpdateNotification(notificationId,
new UpdateNotification() { Seen = seen }));
new UpdateNotification() {Seen = seen}));
}

public override async Task RemoveNotification(string notificationId, CancellationToken token = default)
Expand Down Expand Up @@ -1135,7 +1120,29 @@ public override async Task RemoveStoreUser(string storeId, string userId, Cancel
{
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,
CancellationToken cancellationToken = default)
{
Expand Down
11 changes: 6 additions & 5 deletions BTCPayServer/Controllers/UIAccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ public async Task<IActionResult> Login(LoginViewModel model, string returnUrl =
if (result.IsLockedOut)
{
_logger.LogWarning($"User '{user.Id}' account locked out.");
return RedirectToAction(nameof(Lockout));
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd});
}
else
{
Expand Down Expand Up @@ -428,7 +428,7 @@ public async Task<IActionResult> LoginWith2fa(LoginWith2faViewModel model, bool
else if (result.IsLockedOut)
{
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
return RedirectToAction(nameof(Lockout));
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd});
}
else
{
Expand Down Expand Up @@ -497,7 +497,8 @@ public async Task<IActionResult> LoginWithRecoveryCode(LoginWithRecoveryCodeView
if (result.IsLockedOut)
{
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
return RedirectToAction(nameof(Lockout));

return RedirectToAction(nameof(Lockout), new { user.LockoutEnd});
}
else
{
Expand All @@ -509,9 +510,9 @@ public async Task<IActionResult> LoginWithRecoveryCode(LoginWithRecoveryCodeView

[HttpGet("/login/lockout")]
[AllowAnonymous]
public IActionResult Lockout()
public IActionResult Lockout(DateTimeOffset? lockoutEnd)
{
return View();
return View(lockoutEnd);
}

[HttpGet("/register")]
Expand Down
43 changes: 39 additions & 4 deletions BTCPayServer/Controllers/UIServerController.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ public partial class UIServerController
Id = u.Id,
Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation,
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();
model.Total = await usersQuery.CountAsync();
Expand Down Expand Up @@ -217,12 +218,11 @@ public async Task<IActionResult> DeleteUser(string userId)
var roles = await _UserManager.GetRolesAsync(user);
if (_userService.IsRoleAdmin(roles))
{
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
if (admins.Count == 1)
if (await _userService.IsUserTheOnlyOneAdmin(user))
{
// return
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",
Expand All @@ -245,6 +245,41 @@ public async Task<IActionResult> DeleteUserPost(string userId)
TempData[WellKnownTempData.SuccessMessage] = "User deleted";
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
Expand Down
1 change: 1 addition & 0 deletions BTCPayServer/Models/ServerViewModels/UsersViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public class UserViewModel
public string Name { get; set; }
public string Email { get; set; }
public bool Verified { get; set; }
public bool Disabled { get; set; }
public bool IsAdmin { get; set; }
public DateTimeOffset? Created { get; set; }
public IEnumerable<string> Roles { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ public IEnumerable<PaymentMethodId> GetSupportedPaymentMethods()
new PaymentMethodId(network.CryptoCode, LightningPaymentType.Instance));
}

public async Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
public Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
{
if (settings.Processor != Processor)
{
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));

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@ public IEnumerable<PaymentMethodId> GetSupportedPaymentMethods()
new PaymentMethodId(network.CryptoCode, BitcoinPaymentType.Instance));
}

public async Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
public Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
{
if (settings.Processor != Processor)
{
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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()

var key = await _apiKeyRepository.GetKey(apiKey, true);

if (key == null)
if (key == null || await _userManager.IsLockedOutAsync(key.User))
{
return AuthenticateResult.Fail("ApiKey authentication failed");
}
Expand Down

0 comments on commit 273bc78

Please sign in to comment.