From 288fbda54fd67f302b15a6e92bbe67cba8dbc95a Mon Sep 17 00:00:00 2001 From: Wouter Samaey Date: Tue, 15 Feb 2022 16:19:52 +0100 Subject: [PATCH] New API endpoint: Find 1 user by ID or by email, or list all users. (#3176) Co-authored-by: Kukks --- .../BTCPayServerClient.Users.cs | 12 +++ BTCPayServer.Client/Permissions.cs | 2 + BTCPayServer.Tests/GreenfieldAPITests.cs | 94 +++++++++++++++++++ BTCPayServer.Tests/TestAccount.cs | 7 ++ .../GreenField/GreenfieldUsersController.cs | 32 ++++--- .../Controllers/UIManageController.APIKeys.cs | 1 + BTCPayServer/Services/UserService.cs | 29 +++++- .../wwwroot/swagger/v1/swagger.template.json | 2 +- .../swagger/v1/swagger.template.users.json | 84 ++++++++++++++++- 9 files changed, 246 insertions(+), 17 deletions(-) diff --git a/BTCPayServer.Client/BTCPayServerClient.Users.cs b/BTCPayServer.Client/BTCPayServerClient.Users.cs index 08063878b..bcae8ad81 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Users.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Users.cs @@ -27,6 +27,18 @@ namespace BTCPayServer.Client await HandleResponse(response); } + public virtual async Task GetUserByIdOrEmail(string idOrEmail, CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/{idOrEmail}", null, HttpMethod.Get), token); + return await HandleResponse(response); + } + + public virtual async Task GetUsers( CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token); + return await HandleResponse(response); + } + public virtual async Task DeleteCurrentUser(CancellationToken token = default) { await DeleteUser("me", token); diff --git a/BTCPayServer.Client/Permissions.cs b/BTCPayServer.Client/Permissions.cs index c9faca63a..351791ade 100644 --- a/BTCPayServer.Client/Permissions.cs +++ b/BTCPayServer.Client/Permissions.cs @@ -24,6 +24,7 @@ namespace BTCPayServer.Client public const string CanViewProfile = "btcpay.user.canviewprofile"; public const string CanManageNotificationsForUser = "btcpay.user.canmanagenotificationsforuser"; public const string CanViewNotificationsForUser = "btcpay.user.canviewnotificationsforuser"; + public const string CanViewUsers = "btcpay.server.canviewusers"; public const string CanCreateUser = "btcpay.server.cancreateuser"; public const string CanDeleteUser = "btcpay.user.candeleteuser"; public const string CanManagePullPayments = "btcpay.store.canmanagepullpayments"; @@ -43,6 +44,7 @@ namespace BTCPayServer.Client yield return CanModifyPaymentRequests; yield return CanModifyProfile; yield return CanViewProfile; + yield return CanViewUsers; yield return CanCreateUser; yield return CanDeleteUser; yield return CanManageNotificationsForUser; diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index df7cca19c..7270493eb 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -212,6 +212,100 @@ namespace BTCPayServer.Tests tester.Stores.Remove(user.StoreId); } + + + [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task CanViewUsersViaApi() + { + using var tester = CreateServerTester(newDb: true); + await tester.StartAsync(); + + var unauthClient = new BTCPayServerClient(tester.PayTester.ServerUri); + + // Should be 401 for all calls because we don't have permission + await AssertHttpError(401, async () => await unauthClient.GetUsers()); + await AssertHttpError(401, async () => await unauthClient.GetUserByIdOrEmail("non_existing_id")); + await AssertHttpError(401, async () => await unauthClient.GetUserByIdOrEmail("someone@example.com")); + + + var adminUser = tester.NewAccount(); + await adminUser.GrantAccessAsync(); + await adminUser.MakeAdmin(); + var adminClient = await adminUser.CreateClient(Policies.Unrestricted); + + // Should be 404 if user doesn't exist + await AssertHttpError(404,async () => await adminClient.GetUserByIdOrEmail("non_existing_id")); + await AssertHttpError(404,async () => await adminClient.GetUserByIdOrEmail("doesnotexist@example.com")); + + // Try listing all users, should be fine + await adminClient.GetUsers(); + + // Try loading 1 user by ID. Loading myself. + await adminClient.GetUserByIdOrEmail(adminUser.UserId); + + // Try loading 1 user by email. Loading myself. + await adminClient.GetUserByIdOrEmail(adminUser.Email); + + + // var badClient = await user.CreateClient(Policies.CanCreateInvoice); + // await AssertHttpError(403, + // async () => await badClient.DeleteCurrentUser()); + + var goodUser = tester.NewAccount(); + await goodUser.GrantAccessAsync(); + await goodUser.MakeAdmin(); + var goodClient = await goodUser.CreateClient(Policies.CanViewUsers); + + // Try listing all users, should be fine + await goodClient.GetUsers(); + + // Should be 404 if user doesn't exist + await AssertHttpError(404,async () => await goodClient.GetUserByIdOrEmail("non_existing_id")); + await AssertHttpError(404,async () => await goodClient.GetUserByIdOrEmail("doesnotexist@example.com")); + + // Try listing all users, should be fine + await goodClient.GetUsers(); + + // Try loading 1 user by ID. Loading myself. + await goodClient.GetUserByIdOrEmail(goodUser.UserId); + + // Try loading 1 user by email. Loading myself. + await goodClient.GetUserByIdOrEmail(goodUser.Email); + + + + + var badUser = tester.NewAccount(); + await badUser.GrantAccessAsync(); + await badUser.MakeAdmin(); + + // Bad user has a permission, but it's the wrong one. + var badClient = await goodUser.CreateClient(Policies.CanCreateInvoice); + + // Try listing all users, should be fine + await AssertHttpError(403,async () => await badClient.GetUsers()); + + // Should be 404 if user doesn't exist + await AssertHttpError(403,async () => await badClient.GetUserByIdOrEmail("non_existing_id")); + await AssertHttpError(403,async () => await badClient.GetUserByIdOrEmail("doesnotexist@example.com")); + + // Try listing all users, should be fine + await AssertHttpError(403,async () => await badClient.GetUsers()); + + // Try loading 1 user by ID. Loading myself. + await AssertHttpError(403,async () => await badClient.GetUserByIdOrEmail(badUser.UserId)); + + // 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")] diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 25b37de54..044e98c9a 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -236,6 +236,7 @@ namespace BTCPayServer.Tests } UserId = account.RegisteredUserId; + Email = RegisterDetails.Email; IsAdmin = account.RegisteredAdmin; } @@ -252,6 +253,12 @@ namespace BTCPayServer.Tests get; set; } + + public string Email + { + get; + set; + } public string StoreId { diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs index 630d405e9..23490030b 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs @@ -9,13 +9,10 @@ using BTCPayServer.Client.Models; using BTCPayServer.Configuration; using BTCPayServer.Data; using BTCPayServer.Events; -using BTCPayServer.HostedServices; using BTCPayServer.Logging; using BTCPayServer.Security; using BTCPayServer.Security.Greenfield; using BTCPayServer.Services; -using BTCPayServer.Services.Stores; -using BTCPayServer.Storage.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Identity; @@ -64,6 +61,25 @@ namespace BTCPayServer.Controllers.Greenfield _userService = userService; } + [Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/users/{idOrEmail}")] + public async Task GetUser(string idOrEmail) + { + var user = (await _userManager.FindByIdAsync(idOrEmail) ) ?? await _userManager.FindByEmailAsync(idOrEmail); + if (user != null) + { + return Ok(await FromModel(user)); + } + return UserNotFound(); + } + + [Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/users/")] + public async Task> GetUsers() + { + return Ok(await _userService.GetUsersWithRoles()); + } + [Authorize(Policy = Policies.CanViewProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpGet("~/api/v1/users/me")] public async Task> GetCurrentUser() @@ -216,15 +232,7 @@ namespace BTCPayServer.Controllers.Greenfield private async Task FromModel(ApplicationUser data) { var roles = (await _userManager.GetRolesAsync(data)).ToArray(); - return new ApplicationUserData() - { - Id = data.Id, - Email = data.Email, - EmailConfirmed = data.EmailConfirmed, - RequiresEmailConfirmation = data.RequiresEmailConfirmation, - Roles = roles, - Created = data.Created - }; + return UserService.FromModel(data, roles); } private async Task IsUserTheOnlyOneAdmin() diff --git a/BTCPayServer/Controllers/UIManageController.APIKeys.cs b/BTCPayServer/Controllers/UIManageController.APIKeys.cs index 23169021d..ba3bbbb59 100644 --- a/BTCPayServer/Controllers/UIManageController.APIKeys.cs +++ b/BTCPayServer/Controllers/UIManageController.APIKeys.cs @@ -505,6 +505,7 @@ namespace BTCPayServer.Controllers public static readonly Dictionary PermissionDescriptions = new Dictionary() { {BTCPayServer.Client.Policies.Unrestricted, ("Unrestricted access", "The app will have unrestricted access to your account.")}, + {BTCPayServer.Client.Policies.CanViewUsers, ("View users", "The app will be able to see all users on this server.")}, {BTCPayServer.Client.Policies.CanCreateUser, ("Create new users", "The app will be able to create new users on this server.")}, {BTCPayServer.Client.Policies.CanDeleteUser, ("Delete user", "The app will be able to delete the user to whom it is assigned. Admin users can delete any user without this permission.")}, {BTCPayServer.Client.Policies.CanModifyStoreSettings, ("Modify your stores", "The app will be able to manage invoices on all your stores and modify their settings.")}, diff --git a/BTCPayServer/Services/UserService.cs b/BTCPayServer/Services/UserService.cs index 4ac129c6e..5ce3141bf 100644 --- a/BTCPayServer/Services/UserService.cs +++ b/BTCPayServer/Services/UserService.cs @@ -3,11 +3,13 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Services.Stores; using BTCPayServer.Storage.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; namespace BTCPayServer.Services { @@ -18,13 +20,16 @@ namespace BTCPayServer.Services private readonly StoredFileRepository _storedFileRepository; private readonly FileService _fileService; private readonly StoreRepository _storeRepository; + private readonly ApplicationDbContextFactory _applicationDbContextFactory; public UserService( UserManager userManager, IAuthorizationService authorizationService, StoredFileRepository storedFileRepository, FileService fileService, - StoreRepository storeRepository + StoreRepository storeRepository, + ApplicationDbContextFactory applicationDbContextFactory + ) { _userManager = userManager; @@ -32,6 +37,28 @@ namespace BTCPayServer.Services _storedFileRepository = storedFileRepository; _fileService = fileService; _storeRepository = storeRepository; + _applicationDbContextFactory = applicationDbContextFactory; + } + + public async Task> GetUsersWithRoles() + { + await using var context = _applicationDbContextFactory.CreateContext(); + return await (context.Users.Select(p => FromModel(p, p.UserRoles.Join(context.Roles, userRole => userRole.RoleId, role => role.Id, + (userRole, role) => role.Name).ToArray()))).ToListAsync(); + } + + + public static ApplicationUserData FromModel(ApplicationUser data, string[] roles) + { + return new ApplicationUserData() + { + Id = data.Id, + Email = data.Email, + EmailConfirmed = data.EmailConfirmed, + RequiresEmailConfirmation = data.RequiresEmailConfirmation, + Created = data.Created, + Roles = roles + }; } public async Task IsAdminUser(string userId) diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.json index 6d01f9b18..6dab9f18d 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.json @@ -76,7 +76,7 @@ "securitySchemes": { "API_Key": { "type": "apiKey", - "description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices the lightning nodes associated with your stores\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n", + "description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.canviewusers`: View users\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices the lightning nodes associated with your stores\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n", "name": "Authorization", "in": "header" }, diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json index 628724b09..80ffb420e 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json @@ -52,6 +52,33 @@ } }, "/api/v1/users": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get all users", + "description": "Load all users that exist.", + "parameters": [], + "responses": { + "200": { + "description": "Users found" + }, + "401": { + "description": "Missing authorization for loading the users" + }, + "403": { + "description": "Authorized but forbidden to load the users. You have the wrong API permissions." + } + }, + "security": [ + { + "API_Key": [ + "btcpay.server.canviewusers" + ], + "Basic": [] + } + ] + }, "post": { "tags": [ "Users" @@ -129,7 +156,47 @@ ] } }, - "/api/v1/users/{userId}": { + "/api/v1/users/{idOrEmail}": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get user by ID or Email", + "description": "Get 1 user by ID or Email.", + "parameters": [ + { + "name": "idOrEmail", + "in": "path", + "required": true, + "description": "The ID or email of the user to load", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User found" + }, + "401": { + "description": "Missing authorization for loading the user" + }, + "403": { + "description": "Authorized but forbidden to load the user. You have the wrong API permissions." + }, + "404": { + "description": "No user found with this ID or email" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.server.canviewusers" + ], + "Basic": [] + } + ] + }, "delete": { "tags": [ "Users" @@ -161,7 +228,14 @@ "description": "User with provided ID was not found" } }, - "security": [] + "security": [ + { + "API_Key": [ + "btcpay.user.candeleteuser" + ], + "Basic": [] + } + ] } } }, @@ -192,7 +266,11 @@ "created": { "nullable": true, "description": "The creation date of the user as a unix timestamp. Null if created before v1.0.5.6", - "allOf": [ {"$ref": "#/components/schemas/UnixTimestamp"}] + "allOf": [ + { + "$ref": "#/components/schemas/UnixTimestamp" + } + ] }, "roles": { "type": "array",