From fa51180dfa91c3c9bfa1983647ded233fc0023c2 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 24 Feb 2020 14:36:15 +0100 Subject: [PATCH] Api keys with openiddict (#1262) * Remove OpenIddict * Add API Key system * Revert removing OpenIddict * fix rebase * fix tests * pr changes * fix tests * fix apikey test * pr change * fix db * add migration attrs * fix migration error * PR Changes * Fix sqlite migration * change api key to use Authorization Header * add supportAddForeignKey * use tempdata status message * fix add api key css * remove redirect url + app identifier feature :( --- BTCPayServer.Data/Data/APIKeyData.cs | 30 +- .../Data/ApplicationDbContext.cs | 6 + BTCPayServer.Data/Data/ApplicationUser.cs | 1 + .../20200119130108_ExtendApiKeys.cs | 74 +++++ .../ApplicationDbContextModelSnapshot.cs | 17 + BTCPayServer.Data/MigrationsExtensions.cs | 5 +- BTCPayServer.Tests/ApiKeysTests.cs | 300 ++++++++++++++++++ BTCPayServer.Tests/AuthenticationTests.cs | 19 +- BTCPayServer.Tests/SeleniumTester.cs | 26 +- .../Controllers/ManageController.APIKeys.cs | 295 +++++++++++++++++ BTCPayServer/Controllers/ManageController.cs | 11 +- .../RestApi/TestApiKeyController.cs | 73 +++++ ...tController.cs => TestOpenIdController.cs} | 7 +- BTCPayServer/Hosting/BTCPayServerServices.cs | 15 +- .../APIKeys/APIKeyAuthenticationHandler.cs | 56 ++++ .../APIKeys/APIKeyAuthenticationOptions.cs | 8 + .../APIKeys/APIKeyAuthorizationHandler.cs | 84 +++++ .../Security/APIKeys/APIKeyConstants.cs | 36 +++ .../Security/APIKeys/APIKeyExtensions.cs | 75 +++++ .../Security/APIKeys/APIKeyRepository.cs | 72 +++++ .../Security/AuthenticationSchemes.cs | 1 + .../Security/Bitpay/TokenRepository.cs | 8 +- BTCPayServer/Security/Policies.cs | 5 + .../Services/Stores/StoreRepository.cs | 4 +- BTCPayServer/Views/Manage/APIKeys.cshtml | 50 +++ BTCPayServer/Views/Manage/AddApiKey.cshtml | 120 +++++++ .../Views/Manage/AuthorizeAPIKey.cshtml | 137 ++++++++ BTCPayServer/Views/Manage/ManageNavPages.cs | 2 +- BTCPayServer/Views/Manage/_Nav.cshtml | 9 +- 29 files changed, 1502 insertions(+), 44 deletions(-) create mode 100644 BTCPayServer.Data/Migrations/20200119130108_ExtendApiKeys.cs create mode 100644 BTCPayServer.Tests/ApiKeysTests.cs create mode 100644 BTCPayServer/Controllers/ManageController.APIKeys.cs create mode 100644 BTCPayServer/Controllers/RestApi/TestApiKeyController.cs rename BTCPayServer/Controllers/RestApi/{TestController.cs => TestOpenIdController.cs} (90%) create mode 100644 BTCPayServer/Security/APIKeys/APIKeyAuthenticationHandler.cs create mode 100644 BTCPayServer/Security/APIKeys/APIKeyAuthenticationOptions.cs create mode 100644 BTCPayServer/Security/APIKeys/APIKeyAuthorizationHandler.cs create mode 100644 BTCPayServer/Security/APIKeys/APIKeyConstants.cs create mode 100644 BTCPayServer/Security/APIKeys/APIKeyExtensions.cs create mode 100644 BTCPayServer/Security/APIKeys/APIKeyRepository.cs create mode 100644 BTCPayServer/Views/Manage/APIKeys.cshtml create mode 100644 BTCPayServer/Views/Manage/AddApiKey.cshtml create mode 100644 BTCPayServer/Views/Manage/AuthorizeAPIKey.cshtml diff --git a/BTCPayServer.Data/Data/APIKeyData.cs b/BTCPayServer.Data/Data/APIKeyData.cs index b1a27af6d..715d3f2ec 100644 --- a/BTCPayServer.Data/Data/APIKeyData.cs +++ b/BTCPayServer.Data/Data/APIKeyData.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using System.Threading.Tasks; @@ -11,15 +13,31 @@ namespace BTCPayServer.Data [MaxLength(50)] public string Id { - get; set; + get; + set; } - [MaxLength(50)] - public string StoreId - { - get; set; - } + [MaxLength(50)] public string StoreId { get; set; } + [MaxLength(50)] public string UserId { get; set; } + + public APIKeyType Type { get; set; } = APIKeyType.Legacy; + public string Permissions { get; set; } + public StoreData StoreData { get; set; } + public ApplicationUser User { get; set; } + public string[] GetPermissions() { return Permissions?.Split(';') ?? new string[0]; } + + public void SetPermissions(IEnumerable permissions) + { + Permissions = string.Join(';', + permissions?.Select(s => s.Replace(";", string.Empty)) ?? new string[0]); + } + } + + public enum APIKeyType + { + Legacy, + Permanent } } diff --git a/BTCPayServer.Data/Data/ApplicationDbContext.cs b/BTCPayServer.Data/Data/ApplicationDbContext.cs index a2a24b223..e245b85bc 100644 --- a/BTCPayServer.Data/Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/Data/ApplicationDbContext.cs @@ -160,6 +160,12 @@ namespace BTCPayServer.Data .HasOne(o => o.StoreData) .WithMany(i => i.APIKeys) .HasForeignKey(i => i.StoreId).OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasOne(o => o.User) + .WithMany(i => i.APIKeys) + .HasForeignKey(i => i.UserId).OnDelete(DeleteBehavior.Cascade); + builder.Entity() .HasIndex(o => o.StoreId); diff --git a/BTCPayServer.Data/Data/ApplicationUser.cs b/BTCPayServer.Data/Data/ApplicationUser.cs index fd2b1b5d2..1286e147c 100644 --- a/BTCPayServer.Data/Data/ApplicationUser.cs +++ b/BTCPayServer.Data/Data/ApplicationUser.cs @@ -30,5 +30,6 @@ namespace BTCPayServer.Data } public List U2FDevices { get; set; } + public List APIKeys { get; set; } } } diff --git a/BTCPayServer.Data/Migrations/20200119130108_ExtendApiKeys.cs b/BTCPayServer.Data/Migrations/20200119130108_ExtendApiKeys.cs new file mode 100644 index 000000000..9eeb5b83c --- /dev/null +++ b/BTCPayServer.Data/Migrations/20200119130108_ExtendApiKeys.cs @@ -0,0 +1,74 @@ +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20200119130108_ExtendApiKeys")] + public partial class ExtendApiKeys : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Permissions", + table: "ApiKeys", + nullable: true); + + migrationBuilder.AddColumn( + name: "Type", + table: "ApiKeys", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "UserId", + table: "ApiKeys", + maxLength: 50, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_ApiKeys_UserId", + table: "ApiKeys", + column: "UserId"); + if (this.SupportAddForeignKey(migrationBuilder.ActiveProvider)) + { + migrationBuilder.AddForeignKey( + name: "FK_ApiKeys_AspNetUsers_UserId", + table: "ApiKeys", + column: "UserId", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + if (this.SupportDropForeignKey(migrationBuilder.ActiveProvider)) + { + migrationBuilder.DropForeignKey( + name: "FK_ApiKeys_AspNetUsers_UserId", + table: "ApiKeys"); + } + + migrationBuilder.DropIndex( + name: "IX_ApiKeys_UserId", + table: "ApiKeys"); + if (this.SupportDropColumn(migrationBuilder.ActiveProvider)) + { + migrationBuilder.DropColumn( + name: "Permissions", + table: "ApiKeys"); + + migrationBuilder.DropColumn( + name: "Type", + table: "ApiKeys"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "ApiKeys"); + } + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index 64a958e63..c71ba52bd 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -22,14 +22,26 @@ namespace BTCPayServer.Migrations .HasColumnType("TEXT") .HasMaxLength(50); + b.Property("Permissions") + .HasColumnType("TEXT"); + b.Property("StoreId") .HasColumnType("TEXT") .HasMaxLength(50); + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasMaxLength(50); + b.HasKey("Id"); b.HasIndex("StoreId"); + b.HasIndex("UserId"); + b.ToTable("ApiKeys"); }); @@ -842,6 +854,11 @@ namespace BTCPayServer.Migrations .WithMany("APIKeys") .HasForeignKey("StoreId") .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Data.ApplicationUser", "User") + .WithMany("APIKeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => diff --git a/BTCPayServer.Data/MigrationsExtensions.cs b/BTCPayServer.Data/MigrationsExtensions.cs index 163238b59..fb354dc85 100644 --- a/BTCPayServer.Data/MigrationsExtensions.cs +++ b/BTCPayServer.Data/MigrationsExtensions.cs @@ -11,7 +11,10 @@ namespace BTCPayServer.Migrations { return activeProvider != "Microsoft.EntityFrameworkCore.Sqlite"; } - + public static bool SupportAddForeignKey(this Microsoft.EntityFrameworkCore.Migrations.Migration migration, string activeProvider) + { + return activeProvider != "Microsoft.EntityFrameworkCore.Sqlite"; + } public static bool SupportDropForeignKey(this Microsoft.EntityFrameworkCore.Migrations.Migration migration, string activeProvider) { return activeProvider != "Microsoft.EntityFrameworkCore.Sqlite"; diff --git a/BTCPayServer.Tests/ApiKeysTests.cs b/BTCPayServer.Tests/ApiKeysTests.cs new file mode 100644 index 000000000..f27760243 --- /dev/null +++ b/BTCPayServer.Tests/ApiKeysTests.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Security.APIKeys; +using BTCPayServer.Tests.Logging; +using BTCPayServer.Views.Manage; +using ExchangeSharp; +using Newtonsoft.Json; +using OpenQA.Selenium; +using Xunit; +using Xunit.Abstractions; + +namespace BTCPayServer.Tests +{ + public class ApiKeysTests + { + public const int TestTimeout = TestUtils.TestTimeout; + + public const string TestApiPath = "api/test/apikey"; + public ApiKeysTests(ITestOutputHelper helper) + { + Logs.Tester = new XUnitLog(helper) {Name = "Tests"}; + Logs.LogProvider = new XUnitLogProvider(helper); + } + + [Fact(Timeout = TestTimeout)] + [Trait("Selenium", "Selenium")] + public async Task CanCreateApiKeys() + { + //there are 2 ways to create api keys: + //as a user through your profile + //as an external application requesting an api key from a user + + using (var s = SeleniumTester.Create()) + { + await s.StartAsync(); + var tester = s.Server; + + var user = tester.NewAccount(); + user.GrantAccess(); + + await user.CreateStoreAsync(); + s.GoToLogin(); + s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password); + s.GoToProfile(ManageNavPages.APIKeys); + s.Driver.FindElement(By.Id("AddApiKey")).Click(); + if (!user.IsAdmin) + { + //not an admin, so this permission should not show + Assert.DoesNotContain("ServerManagementPermission", s.Driver.PageSource); + await user.MakeAdmin(); + s.Logout(); + s.GoToLogin(); + s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password); + s.GoToProfile(ManageNavPages.APIKeys); + s.Driver.FindElement(By.Id("AddApiKey")).Click(); + } + + //server management should show now + s.SetCheckbox(s, "ServerManagementPermission", true); + s.SetCheckbox(s, "StoreManagementPermission", true); + s.Driver.FindElement(By.Id("Generate")).Click(); + var superApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text; + + //this api key has access to everything + await TestApiAgainstAccessToken(superApiKey, tester, user, APIKeyConstants.Permissions.ServerManagement, + APIKeyConstants.Permissions.StoreManagement); + + + s.Driver.FindElement(By.Id("AddApiKey")).Click(); + s.SetCheckbox(s, "ServerManagementPermission", true); + s.Driver.FindElement(By.Id("Generate")).Click(); + var serverOnlyApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text; + await TestApiAgainstAccessToken(serverOnlyApiKey, tester, user, + APIKeyConstants.Permissions.ServerManagement); + + + s.Driver.FindElement(By.Id("AddApiKey")).Click(); + s.SetCheckbox(s, "StoreManagementPermission", true); + s.Driver.FindElement(By.Id("Generate")).Click(); + var allStoreOnlyApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text; + await TestApiAgainstAccessToken(allStoreOnlyApiKey, tester, user, + APIKeyConstants.Permissions.StoreManagement); + + s.Driver.FindElement(By.Id("AddApiKey")).Click(); + s.Driver.FindElement(By.CssSelector("button[value=change-store-mode]")).Click(); + //there should be a store already by default in the dropdown + var dropdown = s.Driver.FindElement(By.Name("SpecificStores[0]")); + var option = dropdown.FindElement(By.TagName("option")); + var storeId = option.GetAttribute("value"); + option.Click(); + s.Driver.FindElement(By.Id("Generate")).Click(); + var selectiveStoreApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text; + await TestApiAgainstAccessToken(selectiveStoreApiKey, tester, user, + APIKeyConstants.Permissions.GetStorePermission(storeId)); + + s.Driver.FindElement(By.Id("AddApiKey")).Click(); + s.Driver.FindElement(By.Id("Generate")).Click(); + var noPermissionsApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text; + await TestApiAgainstAccessToken(noPermissionsApiKey, tester, user); + + await Assert.ThrowsAnyAsync(async () => + { + await TestApiAgainstAccessToken("incorrect key", $"{TestApiPath}/me/id", + tester.PayTester.HttpClient); + }); + + + //let's test the authorized screen now + //options for authorize are: + //applicationName + //redirect + //permissions + //strict + //selectiveStores + UriBuilder authorize = new UriBuilder(tester.PayTester.ServerUri); + authorize.Path = "api-keys/authorize"; + + authorize.AppendPayloadToQuery(new Dictionary() + { + {"redirect", "https://local.local/callback"}, + {"applicationName", "kukksappname"}, + {"strict", true}, + {"selectiveStores", false}, + { + "permissions", + new[] + { + APIKeyConstants.Permissions.StoreManagement, + APIKeyConstants.Permissions.ServerManagement + } + }, + }); + var authUrl = authorize.ToString(); + var perms = new[] + { + APIKeyConstants.Permissions.StoreManagement, APIKeyConstants.Permissions.ServerManagement + }; + authUrl = authUrl.Replace("permissions=System.String%5B%5D", + string.Join("&", perms.Select(s1 => $"permissions={s1}"))); + s.Driver.Navigate().GoToUrl(authUrl); + s.Driver.PageSource.Contains("kukksappname"); + Assert.NotNull(s.Driver.FindElement(By.Id("StoreManagementPermission")).GetAttribute("readonly")); + Assert.True(s.Driver.FindElement(By.Id("StoreManagementPermission")).Selected); + Assert.NotNull(s.Driver.FindElement(By.Id("ServerManagementPermission")).GetAttribute("readonly")); + Assert.True(s.Driver.FindElement(By.Id("ServerManagementPermission")).Selected); + Assert.DoesNotContain("change-store-mode", s.Driver.PageSource); + s.Driver.FindElement(By.Id("consent-yes")).Click(); + var url = s.Driver.Url; + IEnumerable> results = url.Split("?").Last().Split("&") + .Select(s1 => new KeyValuePair(s1.Split("=")[0], s1.Split("=")[1])); + + var apiKeyRepo = s.Server.PayTester.GetService(); + + await TestApiAgainstAccessToken(results.Single(pair => pair.Key == "key").Value, tester, user, + (await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).GetPermissions()); + + authorize = new UriBuilder(tester.PayTester.ServerUri); + authorize.Path = "api-keys/authorize"; + authorize.AppendPayloadToQuery(new Dictionary() + { + {"strict", false}, + {"selectiveStores", true}, + { + "permissions", + new[] + { + APIKeyConstants.Permissions.StoreManagement, + APIKeyConstants.Permissions.ServerManagement + } + } + }); + authUrl = authorize.ToString(); + perms = new[] + { + APIKeyConstants.Permissions.StoreManagement, APIKeyConstants.Permissions.ServerManagement + }; + authUrl = authUrl.Replace("permissions=System.String%5B%5D", + string.Join("&", perms.Select(s1 => $"permissions={s1}"))); + s.Driver.Navigate().GoToUrl(authUrl); + Assert.DoesNotContain("kukksappname", s.Driver.PageSource); + + Assert.Null(s.Driver.FindElement(By.Id("StoreManagementPermission")).GetAttribute("readonly")); + Assert.True(s.Driver.FindElement(By.Id("StoreManagementPermission")).Selected); + Assert.Null(s.Driver.FindElement(By.Id("ServerManagementPermission")).GetAttribute("readonly")); + Assert.True(s.Driver.FindElement(By.Id("ServerManagementPermission")).Selected); + + s.SetCheckbox(s, "ServerManagementPermission", false); + Assert.Contains("change-store-mode", s.Driver.PageSource); + s.Driver.FindElement(By.Id("consent-yes")).Click(); + url = s.Driver.Url; + results = url.Split("?").Last().Split("&") + .Select(s1 => new KeyValuePair(s1.Split("=")[0], s1.Split("=")[1])); + + await TestApiAgainstAccessToken(results.Single(pair => pair.Key == "key").Value, tester, user, + (await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).GetPermissions()); + + } + } + + async Task TestApiAgainstAccessToken(string accessToken, ServerTester tester, TestAccount testAccount, + params string[] permissions) + { + var resultUser = + await TestApiAgainstAccessToken(accessToken, $"{TestApiPath}/me/id", + tester.PayTester.HttpClient); + Assert.Equal(testAccount.UserId, resultUser); + + //create a second user to see if any of its data gets messed upin our results. + var secondUser = tester.NewAccount(); + secondUser.GrantAccess(); + + var selectiveStorePermissions = APIKeyConstants.Permissions.ExtractStorePermissionsIds(permissions); + if (permissions.Contains(APIKeyConstants.Permissions.StoreManagement) || selectiveStorePermissions.Any()) + { + var resultStores = + await TestApiAgainstAccessToken(accessToken, $"{TestApiPath}/me/stores", + tester.PayTester.HttpClient); + + foreach (string selectiveStorePermission in selectiveStorePermissions) + { + Assert.True(await TestApiAgainstAccessToken(accessToken, + $"{TestApiPath}/me/stores/{selectiveStorePermission}/can-edit", + tester.PayTester.HttpClient)); + + Assert.Contains(resultStores, + data => data.Id.Equals(selectiveStorePermission, StringComparison.InvariantCultureIgnoreCase)); + } + + if (permissions.Contains(APIKeyConstants.Permissions.StoreManagement)) + { + Assert.True(await TestApiAgainstAccessToken(accessToken, + $"{TestApiPath}/me/stores/actions", + tester.PayTester.HttpClient)); + + Assert.True(await TestApiAgainstAccessToken(accessToken, + $"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit", + tester.PayTester.HttpClient)); + Assert.Contains(resultStores, + data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase)); + } + else + { + await Assert.ThrowsAnyAsync(async () => + { + await TestApiAgainstAccessToken(accessToken, + $"{TestApiPath}/me/stores/actions", + tester.PayTester.HttpClient); + }); + } + + Assert.DoesNotContain(resultStores, + data => data.Id.Equals(secondUser.StoreId, StringComparison.InvariantCultureIgnoreCase)); + } + else + { + await Assert.ThrowsAnyAsync(async () => + { + await TestApiAgainstAccessToken(accessToken, + $"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit", + tester.PayTester.HttpClient); + }); + } + + await Assert.ThrowsAnyAsync(async () => + { + await TestApiAgainstAccessToken(accessToken, $"{TestApiPath}/me/stores/{secondUser.StoreId}/can-edit", + tester.PayTester.HttpClient); + }); + + if (permissions.Contains(APIKeyConstants.Permissions.ServerManagement)) + { + Assert.True(await TestApiAgainstAccessToken(accessToken, + $"{TestApiPath}/me/is-admin", + tester.PayTester.HttpClient)); + } + } + + public async Task TestApiAgainstAccessToken(string apikey, string url, HttpClient client) + { + var httpRequest = new HttpRequestMessage(HttpMethod.Get, + new Uri(client.BaseAddress, url)); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("token", apikey); + var result = await client.SendAsync(httpRequest); + result.EnsureSuccessStatusCode(); + + var rawJson = await result.Content.ReadAsStringAsync(); + if (typeof(T).IsPrimitive || typeof(T) == typeof(string)) + { + return (T)Convert.ChangeType(rawJson, typeof(T)); + } + + return JsonConvert.DeserializeObject(rawJson); + } + } +} diff --git a/BTCPayServer.Tests/AuthenticationTests.cs b/BTCPayServer.Tests/AuthenticationTests.cs index 47cb10f85..805db57f8 100644 --- a/BTCPayServer.Tests/AuthenticationTests.cs +++ b/BTCPayServer.Tests/AuthenticationTests.cs @@ -21,6 +21,7 @@ namespace BTCPayServer.Tests { public class AuthenticationTests { + public const string TestApiPath = "api/test/openid"; public const int TestTimeout = TestUtils.TestTimeout; public AuthenticationTests(ITestOutputHelper helper) { @@ -130,12 +131,12 @@ namespace BTCPayServer.Tests await TestApiAgainstAccessToken(results["access_token"], tester, user); var stores = await TestApiAgainstAccessToken(results["access_token"], - $"api/test/me/stores", + $"{TestApiPath}/me/stores", tester.PayTester.HttpClient); Assert.NotEmpty(stores); Assert.True(await TestApiAgainstAccessToken(results["access_token"], - $"api/test/me/stores/{stores[0].Id}/can-edit", + $"{TestApiPath}/me/stores/{stores[0].Id}/can-edit", tester.PayTester.HttpClient)); //we dont ask for consent after acquiring it the first time for the same scopes. @@ -166,13 +167,13 @@ namespace BTCPayServer.Tests await Assert.ThrowsAnyAsync(async () => { await TestApiAgainstAccessToken(results["access_token"], - $"api/test/me/stores", + $"{TestApiPath}/me/stores", tester.PayTester.HttpClient); }); await Assert.ThrowsAnyAsync(async () => { await TestApiAgainstAccessToken(results["access_token"], - $"api/test/me/stores/{stores[0].Id}/can-edit", + $"{TestApiPath}/me/stores/{stores[0].Id}/can-edit", tester.PayTester.HttpClient); }); } @@ -377,7 +378,7 @@ namespace BTCPayServer.Tests async Task TestApiAgainstAccessToken(string accessToken, ServerTester tester, TestAccount testAccount) { var resultUser = - await TestApiAgainstAccessToken(accessToken, "api/test/me/id", + await TestApiAgainstAccessToken(accessToken, $"{TestApiPath}/me/id", tester.PayTester.HttpClient); Assert.Equal(testAccount.UserId, resultUser); @@ -385,7 +386,7 @@ namespace BTCPayServer.Tests secondUser.GrantAccess(); var resultStores = - await TestApiAgainstAccessToken(accessToken, "api/test/me/stores", + await TestApiAgainstAccessToken(accessToken, $"{TestApiPath}/me/stores", tester.PayTester.HttpClient); Assert.Contains(resultStores, data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase)); @@ -393,16 +394,16 @@ namespace BTCPayServer.Tests data => data.Id.Equals(secondUser.StoreId, StringComparison.InvariantCultureIgnoreCase)); Assert.True(await TestApiAgainstAccessToken(accessToken, - $"api/test/me/stores/{testAccount.StoreId}/can-edit", + $"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit", tester.PayTester.HttpClient)); Assert.True(await TestApiAgainstAccessToken(accessToken, - $"api/test/me/is-admin", + $"{TestApiPath}/me/is-admin", tester.PayTester.HttpClient)); await Assert.ThrowsAnyAsync(async () => { - await TestApiAgainstAccessToken(accessToken, $"api/test/me/stores/{secondUser.StoreId}/can-edit", + await TestApiAgainstAccessToken(accessToken, $"{TestApiPath}/me/stores/{secondUser.StoreId}/can-edit", tester.PayTester.HttpClient); }); } diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 46c2d8767..aba5eec54 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -19,6 +19,7 @@ using System.Threading.Tasks; using BTCPayServer.Lightning; using BTCPayServer.Lightning.CLightning; using BTCPayServer.Models; +using BTCPayServer.Views.Manage; using BTCPayServer.Views.Stores; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -71,19 +72,20 @@ namespace BTCPayServer.Tests Driver.AssertNoError(); } - internal void AssertHappyMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success) + internal IWebElement AssertHappyMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success) { using var cts = new CancellationTokenSource(20_000); while (!cts.IsCancellationRequested) { - var success = Driver.FindElements(By.ClassName($"alert-{StatusMessageModel.ToString(severity)}")).Any(el => el.Displayed); - if (success) - return; + var result = Driver.FindElements(By.ClassName($"alert-{StatusMessageModel.ToString(severity)}")).Where(el => el.Displayed); + if (result.Any()) + return result.First(); Thread.Sleep(100); } Logs.Tester.LogInformation(this.Driver.PageSource); Assert.True(false, $"Should have shown {severity} message"); - } + return null; + } public static readonly TimeSpan ImplicitWait = TimeSpan.FromSeconds(10); public string Link(string relativeLink) @@ -271,6 +273,20 @@ namespace BTCPayServer.Tests { Driver.FindElement(By.Id("Invoices")).Click(); } + + public void GoToProfile(ManageNavPages navPages = ManageNavPages.Index) + { + Driver.FindElement(By.Id("MySettings")).Click(); + if (navPages != ManageNavPages.Index) + { + Driver.FindElement(By.Id(navPages.ToString())).Click(); + } + } + + public void GoToLogin() + { + Driver.Navigate().GoToUrl(new Uri(Server.PayTester.ServerUri, "Account/Login")); + } public void GoToCreateInvoicePage() { diff --git a/BTCPayServer/Controllers/ManageController.APIKeys.cs b/BTCPayServer/Controllers/ManageController.APIKeys.cs new file mode 100644 index 000000000..26ff2aeec --- /dev/null +++ b/BTCPayServer/Controllers/ManageController.APIKeys.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Models; +using BTCPayServer.Security; +using BTCPayServer.Security.APIKeys; +using ExchangeSharp; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; + +namespace BTCPayServer.Controllers +{ + public partial class ManageController + { + [HttpGet] + public async Task APIKeys() + { + return View(new ApiKeysViewModel() + { + ApiKeyDatas = await _apiKeyRepository.GetKeys(new APIKeyRepository.APIKeyQuery() + { + UserId = new[] {_userManager.GetUserId(User)} + }) + }); + } + + [HttpGet] + public async Task RemoveAPIKey(string id) + { + await _apiKeyRepository.Remove(id, _userManager.GetUserId(User)); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = "API Key removed" + }); + return RedirectToAction("APIKeys"); + } + + [HttpGet] + public async Task AddApiKey() + { + if (!_btcPayServerEnvironment.IsSecure) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Error, + Message = "Cannot generate api keys while not on https or tor" + }); + return RedirectToAction("APIKeys"); + } + + return View("AddApiKey", await SetViewModelValues(new AddApiKeyViewModel())); + } + + [HttpGet("~/api-keys/authorize")] + public async Task AuthorizeAPIKey( string[] permissions, string applicationName = null, + bool strict = true, bool selectiveStores = false) + { + if (!_btcPayServerEnvironment.IsSecure) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Error, + Message = "Cannot generate api keys while not on https or tor" + }); + return RedirectToAction("APIKeys"); + } + + permissions ??= Array.Empty(); + + var vm = await SetViewModelValues(new AuthorizeApiKeysViewModel() + { + ServerManagementPermission = permissions.Contains(APIKeyConstants.Permissions.ServerManagement), + StoreManagementPermission = permissions.Contains(APIKeyConstants.Permissions.StoreManagement), + PermissionsFormatted = permissions, + ApplicationName = applicationName, + SelectiveStores = selectiveStores, + Strict = strict, + }); + + vm.ServerManagementPermission = vm.ServerManagementPermission && vm.IsServerAdmin; + return View(vm); + } + + [HttpPost("~/api-keys/authorize")] + public async Task AuthorizeAPIKey([FromForm] AuthorizeApiKeysViewModel viewModel) + { + await SetViewModelValues(viewModel); + var ar = HandleCommands(viewModel); + + if (ar != null) + { + return ar; + } + + + if (viewModel.PermissionsFormatted.Contains(APIKeyConstants.Permissions.ServerManagement)) + { + if (!viewModel.IsServerAdmin && viewModel.ServerManagementPermission) + { + viewModel.ServerManagementPermission = false; + } + + if (!viewModel.ServerManagementPermission && viewModel.Strict) + { + ModelState.AddModelError(nameof(viewModel.ServerManagementPermission), + "This permission is required for this application."); + } + } + + if (viewModel.PermissionsFormatted.Contains(APIKeyConstants.Permissions.StoreManagement)) + { + if (!viewModel.SelectiveStores && + viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific) + { + viewModel.StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.AllStores; + ModelState.AddModelError(nameof(viewModel.StoreManagementPermission), + "This application does not allow selective store permissions."); + } + + if (!viewModel.StoreManagementPermission && !viewModel.SpecificStores.Any() && viewModel.Strict) + { + ModelState.AddModelError(nameof(viewModel.StoreManagementPermission), + "This permission is required for this application."); + } + } + + if (!ModelState.IsValid) + { + return View(viewModel); + } + + switch (viewModel.Command.ToLowerInvariant()) + { + case "no": + return RedirectToAction("APIKeys"); + case "yes": + var key = await CreateKey(viewModel); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Html = $"API key generated! {key.Id}" + }); + return RedirectToAction("APIKeys", new { key = key.Id}); + default: return View(viewModel); + } + } + + [HttpPost] + public async Task AddApiKey(AddApiKeyViewModel viewModel) + { + await SetViewModelValues(viewModel); + + var ar = HandleCommands(viewModel); + + if (ar != null) + { + return ar; + } + + if (!ModelState.IsValid) + { + return View(viewModel); + } + + var key = await CreateKey(viewModel); + + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Html = $"API key generated! {key.Id}" + }); + return RedirectToAction("APIKeys"); + } + private IActionResult HandleCommands(AddApiKeyViewModel viewModel) + { + switch (viewModel.Command) + { + case "change-store-mode": + viewModel.StoreMode = viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific + ? AddApiKeyViewModel.ApiKeyStoreMode.AllStores + : AddApiKeyViewModel.ApiKeyStoreMode.Specific; + + if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific && + !viewModel.SpecificStores.Any() && viewModel.Stores.Any()) + { + viewModel.SpecificStores.Add(null); + } + return View(viewModel); + case "add-store": + viewModel.SpecificStores.Add(null); + return View(viewModel); + + case string x when x.StartsWith("remove-store", StringComparison.InvariantCultureIgnoreCase): + { + ModelState.Clear(); + var index = int.Parse( + viewModel.Command.Substring( + viewModel.Command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1), + CultureInfo.InvariantCulture); + viewModel.SpecificStores.RemoveAt(index); + return View(viewModel); + } + } + + return null; + } + + private async Task CreateKey(AddApiKeyViewModel viewModel) + { + var key = new APIKeyData() + { + Id = Guid.NewGuid().ToString(), Type = APIKeyType.Permanent, UserId = _userManager.GetUserId(User) + }; + key.SetPermissions(GetPermissionsFromViewModel(viewModel)); + await _apiKeyRepository.CreateKey(key); + return key; + } + + private IEnumerable GetPermissionsFromViewModel(AddApiKeyViewModel viewModel) + { + var permissions = new List(); + + if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific) + { + permissions.AddRange(viewModel.SpecificStores.Select(APIKeyConstants.Permissions.GetStorePermission)); + } + else if (viewModel.StoreManagementPermission) + { + permissions.Add(APIKeyConstants.Permissions.StoreManagement); + } + + if (viewModel.IsServerAdmin && viewModel.ServerManagementPermission) + { + permissions.Add(APIKeyConstants.Permissions.ServerManagement); + } + + return permissions; + } + + private async Task SetViewModelValues(T viewModel) where T : AddApiKeyViewModel + { + viewModel.Stores = await _StoreRepository.GetStoresByUserId(_userManager.GetUserId(User)); + viewModel.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings.Key)).Succeeded; + return viewModel; + } + + public class AddApiKeyViewModel + { + public StoreData[] Stores { get; set; } + public ApiKeyStoreMode StoreMode { get; set; } + public List SpecificStores { get; set; } = new List(); + public bool IsServerAdmin { get; set; } + public bool ServerManagementPermission { get; set; } + public bool StoreManagementPermission { get; set; } + public string Command { get; set; } + + public enum ApiKeyStoreMode + { + AllStores, + Specific + } + } + + public class AuthorizeApiKeysViewModel : AddApiKeyViewModel + { + public string ApplicationName { get; set; } + public bool Strict { get; set; } + public bool SelectiveStores { get; set; } + public string Permissions { get; set; } + + public string[] PermissionsFormatted + { + get + { + return Permissions?.Split(";", StringSplitOptions.RemoveEmptyEntries); + } + set + { + Permissions = string.Join(';', value ?? Array.Empty()); + } + } + } + + + public class ApiKeysViewModel + { + public List ApiKeyDatas { get; set; } + } + } +} diff --git a/BTCPayServer/Controllers/ManageController.cs b/BTCPayServer/Controllers/ManageController.cs index 46087772d..357f96e8e 100644 --- a/BTCPayServer/Controllers/ManageController.cs +++ b/BTCPayServer/Controllers/ManageController.cs @@ -19,6 +19,8 @@ using System.Globalization; using BTCPayServer.Security; using BTCPayServer.U2F; using BTCPayServer.Data; +using BTCPayServer.Security.APIKeys; + namespace BTCPayServer.Controllers { @@ -34,6 +36,8 @@ namespace BTCPayServer.Controllers IWebHostEnvironment _Env; public U2FService _u2FService; private readonly BTCPayServerEnvironment _btcPayServerEnvironment; + private readonly APIKeyRepository _apiKeyRepository; + private readonly IAuthorizationService _authorizationService; StoreRepository _StoreRepository; @@ -48,7 +52,10 @@ namespace BTCPayServer.Controllers StoreRepository storeRepository, IWebHostEnvironment env, U2FService u2FService, - BTCPayServerEnvironment btcPayServerEnvironment) + BTCPayServerEnvironment btcPayServerEnvironment, + APIKeyRepository apiKeyRepository, + IAuthorizationService authorizationService + ) { _userManager = userManager; _signInManager = signInManager; @@ -58,6 +65,8 @@ namespace BTCPayServer.Controllers _Env = env; _u2FService = u2FService; _btcPayServerEnvironment = btcPayServerEnvironment; + _apiKeyRepository = apiKeyRepository; + _authorizationService = authorizationService; _StoreRepository = storeRepository; } diff --git a/BTCPayServer/Controllers/RestApi/TestApiKeyController.cs b/BTCPayServer/Controllers/RestApi/TestApiKeyController.cs new file mode 100644 index 000000000..d47a732dd --- /dev/null +++ b/BTCPayServer/Controllers/RestApi/TestApiKeyController.cs @@ -0,0 +1,73 @@ +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Security; +using BTCPayServer.Security.APIKeys; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.Controllers.RestApi +{ + /// + /// this controller serves as a testing endpoint for our api key unit tests + /// + [Route("api/test/apikey")] + [ApiController] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.ApiKey)] + public class TestApiKeyController : ControllerBase + { + private readonly UserManager _userManager; + private readonly StoreRepository _storeRepository; + + public TestApiKeyController(UserManager userManager, StoreRepository storeRepository) + { + _userManager = userManager; + _storeRepository = storeRepository; + } + + [HttpGet("me/id")] + public string GetCurrentUserId() + { + return _userManager.GetUserId(User); + } + + [HttpGet("me")] + public async Task GetCurrentUser() + { + return await _userManager.GetUserAsync(User); + } + + [HttpGet("me/is-admin")] + [Authorize(Policy = Policies.CanModifyServerSettings.Key, AuthenticationSchemes = AuthenticationSchemes.ApiKey)] + public bool AmIAnAdmin() + { + return true; + } + + [HttpGet("me/stores")] + [Authorize(Policy = Policies.CanListStoreSettings.Key, + AuthenticationSchemes = AuthenticationSchemes.ApiKey)] + public async Task GetCurrentUserStores() + { + return await User.GetStores(_userManager, _storeRepository); + } + + [HttpGet("me/stores/actions")] + [Authorize(Policy = Policies.CanModifyStoreSettings.Key, + AuthenticationSchemes = AuthenticationSchemes.ApiKey)] + public bool CanDoNonImplicitStoreActions() + { + return true; + } + + + [HttpGet("me/stores/{storeId}/can-edit")] + [Authorize(Policy = Policies.CanModifyStoreSettings.Key, + AuthenticationSchemes = AuthenticationSchemes.ApiKey)] + public bool CanEdit(string storeId) + { + return true; + } + } +} diff --git a/BTCPayServer/Controllers/RestApi/TestController.cs b/BTCPayServer/Controllers/RestApi/TestOpenIdController.cs similarity index 90% rename from BTCPayServer/Controllers/RestApi/TestController.cs rename to BTCPayServer/Controllers/RestApi/TestOpenIdController.cs index 4ab4d7dbe..0e5623d29 100644 --- a/BTCPayServer/Controllers/RestApi/TestController.cs +++ b/BTCPayServer/Controllers/RestApi/TestOpenIdController.cs @@ -13,15 +13,15 @@ namespace BTCPayServer.Controllers.RestApi /// /// this controller serves as a testing endpoint for our OpenId unit tests /// - [Route("api/[controller]")] + [Route("api/test/openid")] [ApiController] [Authorize(AuthenticationSchemes = AuthenticationSchemes.OpenId)] - public class TestController : ControllerBase + public class TestOpenIdController : ControllerBase { private readonly UserManager _userManager; private readonly StoreRepository _storeRepository; - public TestController(UserManager userManager, StoreRepository storeRepository) + public TestOpenIdController(UserManager userManager, StoreRepository storeRepository) { _userManager = userManager; _storeRepository = storeRepository; @@ -54,7 +54,6 @@ namespace BTCPayServer.Controllers.RestApi return await _storeRepository.GetStoresByUserId(_userManager.GetUserId(User)); } - [HttpGet("me/stores/{storeId}/can-edit")] [Authorize(Policy = Policies.CanModifyStoreSettings.Key, AuthenticationSchemes = AuthenticationSchemes.OpenId)] diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index be19bd8fe..a9064f044 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -32,6 +32,7 @@ using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Changelly; using BTCPayServer.Payments.Lightning; using BTCPayServer.Security; +using BTCPayServer.Security.APIKeys; using BTCPayServer.Services.PaymentRequests; using Microsoft.AspNetCore.Mvc.ModelBinding; using NBXplorer.DerivationStrategy; @@ -241,11 +242,11 @@ namespace BTCPayServer.Hosting services.AddTransient(); // Add application services. services.AddSingleton(); - // bundling - - services.AddBtcPayServerAuthenticationSchemes(configuration); + + services.AddAPIKeyAuthentication(); + services.AddBtcPayServerAuthenticationSchemes(); services.AddAuthorization(o => o.AddBTCPayPolicies()); - + // bundling services.AddSingleton(); services.AddTransient(provider => { @@ -292,12 +293,12 @@ namespace BTCPayServer.Hosting return services; } private const long MAX_DEBUG_LOG_FILE_SIZE = 2000000; // If debug log is in use roll it every N MB. - private static void AddBtcPayServerAuthenticationSchemes(this IServiceCollection services, - IConfiguration configuration) + private static void AddBtcPayServerAuthenticationSchemes(this IServiceCollection services) { services.AddAuthentication() .AddCookie() - .AddBitpayAuthentication(); + .AddBitpayAuthentication() + .AddAPIKeyAuthentication(); } public static IApplicationBuilder UsePayServer(this IApplicationBuilder app) diff --git a/BTCPayServer/Security/APIKeys/APIKeyAuthenticationHandler.cs b/BTCPayServer/Security/APIKeys/APIKeyAuthenticationHandler.cs new file mode 100644 index 000000000..056b48180 --- /dev/null +++ b/BTCPayServer/Security/APIKeys/APIKeyAuthenticationHandler.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Security.Bitpay; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace BTCPayServer.Security.APIKeys +{ + public class APIKeyAuthenticationHandler : AuthenticationHandler + { + private readonly APIKeyRepository _apiKeyRepository; + private readonly IOptionsMonitor _identityOptions; + + public APIKeyAuthenticationHandler( + APIKeyRepository apiKeyRepository, + IOptionsMonitor identityOptions, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) : base(options, logger, encoder, clock) + { + _apiKeyRepository = apiKeyRepository; + _identityOptions = identityOptions; + } + + protected override async Task HandleAuthenticateAsync() + { + if (!Context.Request.HttpContext.GetAPIKey(out var apiKey) || string.IsNullOrEmpty(apiKey)) + return AuthenticateResult.NoResult(); + + var key = await _apiKeyRepository.GetKey(apiKey); + + if (key == null) + { + return AuthenticateResult.Fail("ApiKey authentication failed"); + } + + List claims = new List(); + + claims.Add(new Claim(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId)); + claims.AddRange(key.GetPermissions() + .Select(permission => new Claim(APIKeyConstants.ClaimTypes.Permissions, permission))); + + return AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity(claims, APIKeyConstants.AuthenticationType)), APIKeyConstants.AuthenticationType)); + } + } +} diff --git a/BTCPayServer/Security/APIKeys/APIKeyAuthenticationOptions.cs b/BTCPayServer/Security/APIKeys/APIKeyAuthenticationOptions.cs new file mode 100644 index 000000000..d03e796cd --- /dev/null +++ b/BTCPayServer/Security/APIKeys/APIKeyAuthenticationOptions.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authentication; + +namespace BTCPayServer.Security.Bitpay +{ + public class APIKeyAuthenticationOptions : AuthenticationSchemeOptions + { + } +} diff --git a/BTCPayServer/Security/APIKeys/APIKeyAuthorizationHandler.cs b/BTCPayServer/Security/APIKeys/APIKeyAuthorizationHandler.cs new file mode 100644 index 000000000..0ed687374 --- /dev/null +++ b/BTCPayServer/Security/APIKeys/APIKeyAuthorizationHandler.cs @@ -0,0 +1,84 @@ +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; + +namespace BTCPayServer.Security.APIKeys +{ + public class APIKeyAuthorizationHandler : AuthorizationHandler + + { + private readonly HttpContext _HttpContext; + private readonly UserManager _userManager; + private readonly StoreRepository _storeRepository; + + public APIKeyAuthorizationHandler(IHttpContextAccessor httpContextAccessor, + UserManager userManager, + StoreRepository storeRepository) + { + _HttpContext = httpContextAccessor.HttpContext; + _userManager = userManager; + _storeRepository = storeRepository; + } + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, + PolicyRequirement requirement) + { + if (context.User.Identity.AuthenticationType != APIKeyConstants.AuthenticationType) + return; + + bool success = false; + switch (requirement.Policy) + { + case Policies.CanListStoreSettings.Key: + var selectiveStorePermissions = + APIKeyConstants.Permissions.ExtractStorePermissionsIds(context.GetPermissions()); + success = context.HasPermissions(APIKeyConstants.Permissions.StoreManagement) || + selectiveStorePermissions.Any(); + break; + case Policies.CanModifyStoreSettings.Key: + string storeId = _HttpContext.GetImplicitStoreId(); + if (!context.HasPermissions(APIKeyConstants.Permissions.StoreManagement) && + !context.HasPermissions(APIKeyConstants.Permissions.GetStorePermission(storeId))) + break; + + if (storeId == null) + { + success = true; + } + else + { + var userid = _userManager.GetUserId(context.User); + if (string.IsNullOrEmpty(userid)) + break; + var store = await _storeRepository.FindStore((string)storeId, userid); + if (store == null) + break; + success = true; + _HttpContext.SetStoreData(store); + } + + break; + case Policies.CanModifyServerSettings.Key: + if (!context.HasPermissions(APIKeyConstants.Permissions.ServerManagement)) + break; + // For this authorization, we stil check in database because it is super sensitive. + var user = await _userManager.GetUserAsync(context.User); + if (user == null) + break; + if (!await _userManager.IsInRoleAsync(user, Roles.ServerAdmin)) + break; + success = true; + break; + } + + if (success) + { + context.Succeed(requirement); + } + } + } +} diff --git a/BTCPayServer/Security/APIKeys/APIKeyConstants.cs b/BTCPayServer/Security/APIKeys/APIKeyConstants.cs new file mode 100644 index 000000000..427de68e5 --- /dev/null +++ b/BTCPayServer/Security/APIKeys/APIKeyConstants.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace BTCPayServer.Security.APIKeys +{ + public static class APIKeyConstants + { + public const string AuthenticationType = "APIKey"; + + public static class ClaimTypes + { + public const string Permissions = nameof(APIKeys) + "." + nameof(Permissions); + } + + public static class Permissions + { + public const string ServerManagement = nameof(ServerManagement); + public const string StoreManagement = nameof(StoreManagement); + + public static readonly Dictionary PermissionDescriptions = new Dictionary() + { + {StoreManagement, ("Manage your stores", "The app will be able to create, modify and delete all your stores.")}, + {$"{nameof(StoreManagement)}:", ("Manage selected stores", "The app will be able to modify and delete selected stores.")}, + {ServerManagement, ("Manage your server", "The app will have total control on your server")}, + }; + + public static string GetStorePermission(string storeId) => $"{nameof(StoreManagement)}:{storeId}"; + + public static IEnumerable ExtractStorePermissionsIds(IEnumerable permissions) => permissions + .Where(s => s.StartsWith($"{nameof(StoreManagement)}:", StringComparison.InvariantCulture)) + .Select(s => s.Split(":")[1]); + } + } +} diff --git a/BTCPayServer/Security/APIKeys/APIKeyExtensions.cs b/BTCPayServer/Security/APIKeys/APIKeyExtensions.cs new file mode 100644 index 000000000..17f696f0d --- /dev/null +++ b/BTCPayServer/Security/APIKeys/APIKeyExtensions.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Security.Bitpay; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; + +namespace BTCPayServer.Security.APIKeys +{ + public static class APIKeyExtensions + { + public static bool GetAPIKey(this HttpContext httpContext, out StringValues apiKey) + { + if (httpContext.Request.Headers.TryGetValue("Authorization", out var value) && + value.ToString().StartsWith("token ", StringComparison.InvariantCultureIgnoreCase)) + { + apiKey = value.ToString().Substring("token ".Length); + return true; + } + + return false; + } + + public static Task GetStores(this ClaimsPrincipal claimsPrincipal, + UserManager userManager, StoreRepository storeRepository) + { + var permissions = + claimsPrincipal.Claims.Where(claim => claim.Type == APIKeyConstants.ClaimTypes.Permissions) + .Select(claim => claim.Value).ToList(); + + if (permissions.Contains(APIKeyConstants.Permissions.StoreManagement)) + { + return storeRepository.GetStoresByUserId(userManager.GetUserId(claimsPrincipal)); + } + + var storeIds = APIKeyConstants.Permissions.ExtractStorePermissionsIds(permissions); + return storeRepository.GetStoresByUserId(userManager.GetUserId(claimsPrincipal), storeIds); + } + + public static AuthenticationBuilder AddAPIKeyAuthentication(this AuthenticationBuilder builder) + { + builder.AddScheme(AuthenticationSchemes.ApiKey, + o => { }); + return builder; + } + + public static IServiceCollection AddAPIKeyAuthentication(this IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton(); + serviceCollection.AddScoped(); + return serviceCollection; + } + + public static string[] GetPermissions(this AuthorizationHandlerContext context) + { + return context.User.Claims.Where(c => + c.Type.Equals(APIKeyConstants.ClaimTypes.Permissions, StringComparison.InvariantCultureIgnoreCase)) + .Select(claim => claim.Value).ToArray(); + } + + public static bool HasPermissions(this AuthorizationHandlerContext context, params string[] scopes) + { + return scopes.All(s => context.User.HasClaim(c => + c.Type.Equals(APIKeyConstants.ClaimTypes.Permissions, StringComparison.InvariantCultureIgnoreCase) && + c.Value.Split(' ').Contains(s))); + } + } +} diff --git a/BTCPayServer/Security/APIKeys/APIKeyRepository.cs b/BTCPayServer/Security/APIKeys/APIKeyRepository.cs new file mode 100644 index 000000000..c30fd7a8c --- /dev/null +++ b/BTCPayServer/Security/APIKeys/APIKeyRepository.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Security.APIKeys +{ + public class APIKeyRepository + { + private readonly ApplicationDbContextFactory _applicationDbContextFactory; + + public APIKeyRepository(ApplicationDbContextFactory applicationDbContextFactory) + { + _applicationDbContextFactory = applicationDbContextFactory; + } + + public async Task GetKey(string apiKey) + { + using (var context = _applicationDbContextFactory.CreateContext()) + { + return await EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(context.ApiKeys, + data => data.Id == apiKey && data.Type != APIKeyType.Legacy); + } + } + + public async Task> GetKeys(APIKeyQuery query) + { + using (var context = _applicationDbContextFactory.CreateContext()) + { + var queryable = context.ApiKeys.AsQueryable(); + if (query?.UserId != null && query.UserId.Any()) + { + queryable = queryable.Where(data => query.UserId.Contains(data.UserId)); + } + + return await queryable.ToListAsync(); + } + } + + public async Task CreateKey(APIKeyData key) + { + if (key.Type == APIKeyType.Legacy || !string.IsNullOrEmpty(key.StoreId) || string.IsNullOrEmpty(key.UserId)) + { + throw new InvalidOperationException("cannot save a bitpay legacy api key with this repository"); + } + + using (var context = _applicationDbContextFactory.CreateContext()) + { + await context.ApiKeys.AddAsync(key); + await context.SaveChangesAsync(); + } + } + + public async Task Remove(string id, string getUserId) + { + using (var context = _applicationDbContextFactory.CreateContext()) + { + var key = await EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(context.ApiKeys, + data => data.Id == id && data.UserId == getUserId); + context.ApiKeys.Remove(key); + await context.SaveChangesAsync(); + } + } + + public class APIKeyQuery + { + public string[] UserId { get; set; } + } + } +} diff --git a/BTCPayServer/Security/AuthenticationSchemes.cs b/BTCPayServer/Security/AuthenticationSchemes.cs index 0f986f4e2..a3117ca3a 100644 --- a/BTCPayServer/Security/AuthenticationSchemes.cs +++ b/BTCPayServer/Security/AuthenticationSchemes.cs @@ -12,5 +12,6 @@ namespace BTCPayServer.Security public const string Cookie = "Identity.Application"; public const string Bitpay = "Bitpay"; public const string OpenId = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + public const string ApiKey = "GreenfieldApiKey"; } } diff --git a/BTCPayServer/Security/Bitpay/TokenRepository.cs b/BTCPayServer/Security/Bitpay/TokenRepository.cs index cb0289a52..b5ac7fa3c 100644 --- a/BTCPayServer/Security/Bitpay/TokenRepository.cs +++ b/BTCPayServer/Security/Bitpay/TokenRepository.cs @@ -66,10 +66,10 @@ namespace BTCPayServer.Security.Bitpay using (var ctx = _Factory.CreateContext()) { - var existing = await ctx.ApiKeys.Where(o => o.StoreId == storeId).FirstOrDefaultAsync(); - if (existing != null) + var existing = await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type == APIKeyType.Legacy).ToListAsync(); + if (existing.Any()) { - ctx.ApiKeys.Remove(existing); + ctx.ApiKeys.RemoveRange(existing); } ctx.ApiKeys.Add(new APIKeyData() { Id = new string(generated), StoreId = storeId }); await ctx.SaveChangesAsync().ConfigureAwait(false); @@ -95,7 +95,7 @@ namespace BTCPayServer.Security.Bitpay { using (var ctx = _Factory.CreateContext()) { - return await ctx.ApiKeys.Where(o => o.StoreId == storeId).Select(c => c.Id).ToArrayAsync(); + return await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type== APIKeyType.Legacy).Select(c => c.Id).ToArrayAsync(); } } diff --git a/BTCPayServer/Security/Policies.cs b/BTCPayServer/Security/Policies.cs index ad2a711f0..e21c10c95 100644 --- a/BTCPayServer/Security/Policies.cs +++ b/BTCPayServer/Security/Policies.cs @@ -11,6 +11,7 @@ namespace BTCPayServer.Security public static AuthorizationOptions AddBTCPayPolicies(this AuthorizationOptions options) { options.AddPolicy(CanModifyStoreSettings.Key); + options.AddPolicy(CanListStoreSettings.Key); options.AddPolicy(CanCreateInvoice.Key); options.AddPolicy(CanGetRates.Key); options.AddPolicy(CanModifyServerSettings.Key); @@ -30,6 +31,10 @@ namespace BTCPayServer.Security { public const string Key = "btcpay.store.canmodifystoresettings"; } + public class CanListStoreSettings + { + public const string Key = "btcpay.store.canliststoresettings"; + } public class CanCreateInvoice { public const string Key = "btcpay.store.cancreateinvoice"; diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs index 8f202cc1b..09c1e6cac 100644 --- a/BTCPayServer/Services/Stores/StoreRepository.cs +++ b/BTCPayServer/Services/Stores/StoreRepository.cs @@ -78,12 +78,12 @@ namespace BTCPayServer.Services.Stores } } - public async Task GetStoresByUserId(string userId) + public async Task GetStoresByUserId(string userId, IEnumerable storeIds = null) { using (var ctx = _ContextFactory.CreateContext()) { return (await ctx.UserStore - .Where(u => u.ApplicationUserId == userId) + .Where(u => u.ApplicationUserId == userId && (storeIds == null || storeIds.Contains(u.StoreDataId))) .Select(u => new { u.StoreData, u.Role }) .ToArrayAsync()) .Select(u => diff --git a/BTCPayServer/Views/Manage/APIKeys.cshtml b/BTCPayServer/Views/Manage/APIKeys.cshtml new file mode 100644 index 000000000..23db9ac73 --- /dev/null +++ b/BTCPayServer/Views/Manage/APIKeys.cshtml @@ -0,0 +1,50 @@ +@model BTCPayServer.Controllers.ManageController.ApiKeysViewModel +@{ + ViewData.SetActivePageAndTitle(ManageNavPages.APIKeys, "Manage your API Keys"); +} + + +

API Keys

+ + + + + + + + + + @foreach (var keyData in Model.ApiKeyDatas) + { + + + + + + } + @if (!Model.ApiKeyDatas.Any()) + { + + + + } + + + + +
KeyPermissionsActions
@keyData.Id + @if (string.IsNullOrEmpty(keyData.Permissions)) + { + No permissions + } + else + { + @string.Join(", ", keyData.GetPermissions()) + } + + Remove +
+ No API keys +
+ Generate new key +
diff --git a/BTCPayServer/Views/Manage/AddApiKey.cshtml b/BTCPayServer/Views/Manage/AddApiKey.cshtml new file mode 100644 index 000000000..e992c0a38 --- /dev/null +++ b/BTCPayServer/Views/Manage/AddApiKey.cshtml @@ -0,0 +1,120 @@ +@using BTCPayServer.Controllers +@using BTCPayServer.Security.APIKeys +@model BTCPayServer.Controllers.ManageController.AddApiKeyViewModel + +@{ + ViewData.SetActivePageAndTitle(ManageNavPages.APIKeys, "Add API Key"); + + string GetDescription(string permission) + { + return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description; + } + + string GetTitle(string permission) + { + return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description; + } +} + +

@ViewData["Title"]

+ +

+ Generate a new api key to use BTCPay through its API. +

+
+
+
+ + +
+ @if (Model.IsServerAdmin) + { +
+ + + +

@GetDescription(APIKeyConstants.Permissions.ServerManagement).

+
+ } + @if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores) + { +
+ @Html.CheckBoxFor(model => model.StoreManagementPermission, new Dictionary() {{"class", "form-check-inline"}}) + + + +

@GetDescription(APIKeyConstants.Permissions.StoreManagement).

+ +
+ } + else if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.Specific) + { +
+
  • +
    @GetTitle(APIKeyConstants.Permissions.StoreManagement + ":")
    +

    @GetDescription(APIKeyConstants.Permissions.StoreManagement + ":").

    + +
  • + @if (!Model.Stores.Any()) + { +
  • + You currently have no stores configured. +
  • + } + @for (var index = 0; index < Model.SpecificStores.Count; index++) + { +
    +
    +
    +
    + @if (Model.SpecificStores[index] == null) + { + + } + else + { + var store = Model.Stores.SingleOrDefault(data => data.Id == Model.SpecificStores[index]); + + } + + +
    +
    +
    + + +
    +
    +
    + } + @if (Model.SpecificStores.Count < Model.Stores.Length) + { +
    + +
    + } +
    + } + +
    +
    +
    + +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") + + +} diff --git a/BTCPayServer/Views/Manage/AuthorizeAPIKey.cshtml b/BTCPayServer/Views/Manage/AuthorizeAPIKey.cshtml new file mode 100644 index 000000000..136d480a3 --- /dev/null +++ b/BTCPayServer/Views/Manage/AuthorizeAPIKey.cshtml @@ -0,0 +1,137 @@ +@using BTCPayServer.Controllers +@using BTCPayServer.Security.APIKeys +@model BTCPayServer.Controllers.ManageController.AuthorizeApiKeysViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = $"Authorize {(Model.ApplicationName ?? "Application")}"; + + string GetDescription(string permission) + { + return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description; + } + + string GetTitle(string permission) + { + return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description; + } +} + + +
    + + + + +
    +
    +
    +
    +

    Authorization Request

    +
    +

    @(Model.ApplicationName ?? "An application") is requesting access to your account.

    +
    +
    +
    +
    +
    + @if (Model.PermissionsFormatted.Contains(APIKeyConstants.Permissions.ServerManagement) && (Model.IsServerAdmin || Model.Strict)) + { +
    + + + @if (!Model.IsServerAdmin) + { + + The server management permission is being requested but your account is not an administrator + + } + + +

    @GetDescription(APIKeyConstants.Permissions.ServerManagement).

    +
    + } + + @if (Model.PermissionsFormatted.Contains(APIKeyConstants.Permissions.StoreManagement)) + { + @if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores) + { +
    + + + +

    @GetDescription(APIKeyConstants.Permissions.StoreManagement).

    + @if (Model.SelectiveStores) + { + + } +
    + } + else if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.Specific) + { +
    +
  • +
    @GetTitle(APIKeyConstants.Permissions.StoreManagement + ":")
    +

    @GetDescription(APIKeyConstants.Permissions.StoreManagement + ":").

    + +
  • + @if (!Model.Stores.Any()) + { +
  • + You currently have no stores configured. +
  • + } + @for (var index = 0; index < Model.SpecificStores.Count; index++) + { +
    +
    +
    +
    + @if (Model.SpecificStores[index] == null) + { + + } + else + { + var store = Model.Stores.SingleOrDefault(data => data.Id == Model.SpecificStores[index]); + + } + + +
    +
    +
    + + +
    +
    +
    + } + @if (Model.SpecificStores.Count < Model.Stores.Length) + { +
    + +
    + } +
    + } + } +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    diff --git a/BTCPayServer/Views/Manage/ManageNavPages.cs b/BTCPayServer/Views/Manage/ManageNavPages.cs index 6227b33d7..c197c829b 100644 --- a/BTCPayServer/Views/Manage/ManageNavPages.cs +++ b/BTCPayServer/Views/Manage/ManageNavPages.cs @@ -7,6 +7,6 @@ namespace BTCPayServer.Views.Manage { public enum ManageNavPages { - Index, ChangePassword, TwoFactorAuthentication, U2F + Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys } } diff --git a/BTCPayServer/Views/Manage/_Nav.cshtml b/BTCPayServer/Views/Manage/_Nav.cshtml index 7c27d57cf..8c613b3c6 100644 --- a/BTCPayServer/Views/Manage/_Nav.cshtml +++ b/BTCPayServer/Views/Manage/_Nav.cshtml @@ -1,9 +1,10 @@ @inject SignInManager SignInManager