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 :(
This commit is contained in:
Andrew Camilleri 2020-02-24 14:36:15 +01:00 committed by GitHub
parent a3e7729c52
commit fa51180dfa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1502 additions and 44 deletions

View file

@ -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<string> permissions)
{
Permissions = string.Join(';',
permissions?.Select(s => s.Replace(";", string.Empty)) ?? new string[0]);
}
}
public enum APIKeyType
{
Legacy,
Permanent
}
}

View file

@ -160,6 +160,12 @@ namespace BTCPayServer.Data
.HasOne(o => o.StoreData)
.WithMany(i => i.APIKeys)
.HasForeignKey(i => i.StoreId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<APIKeyData>()
.HasOne(o => o.User)
.WithMany(i => i.APIKeys)
.HasForeignKey(i => i.UserId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<APIKeyData>()
.HasIndex(o => o.StoreId);

View file

@ -30,5 +30,6 @@ namespace BTCPayServer.Data
}
public List<U2FDevice> U2FDevices { get; set; }
public List<APIKeyData> APIKeys { get; set; }
}
}

View file

@ -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<string>(
name: "Permissions",
table: "ApiKeys",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Type",
table: "ApiKeys",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
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");
}
}
}
}

View file

@ -22,14 +22,26 @@ namespace BTCPayServer.Migrations
.HasColumnType("TEXT")
.HasMaxLength(50);
b.Property<string>("Permissions")
.HasColumnType("TEXT");
b.Property<string>("StoreId")
.HasColumnType("TEXT")
.HasMaxLength(50);
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<string>("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 =>

View file

@ -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";

View file

@ -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<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>("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<string, object>()
{
{"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<KeyValuePair<string, string>> results = url.Split("?").Last().Split("&")
.Select(s1 => new KeyValuePair<string, string>(s1.Split("=")[0], s1.Split("=")[1]));
var apiKeyRepo = s.Server.PayTester.GetService<APIKeyRepository>();
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<string, object>()
{
{"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<string, string>(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<string>(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<StoreData[]>(accessToken, $"{TestApiPath}/me/stores",
tester.PayTester.HttpClient);
foreach (string selectiveStorePermission in selectiveStorePermissions)
{
Assert.True(await TestApiAgainstAccessToken<bool>(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<bool>(accessToken,
$"{TestApiPath}/me/stores/actions",
tester.PayTester.HttpClient));
Assert.True(await TestApiAgainstAccessToken<bool>(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<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(accessToken,
$"{TestApiPath}/me/stores/actions",
tester.PayTester.HttpClient);
});
}
Assert.DoesNotContain(resultStores,
data => data.Id.Equals(secondUser.StoreId, StringComparison.InvariantCultureIgnoreCase));
}
else
{
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(accessToken,
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit",
tester.PayTester.HttpClient);
});
}
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(accessToken, $"{TestApiPath}/me/stores/{secondUser.StoreId}/can-edit",
tester.PayTester.HttpClient);
});
if (permissions.Contains(APIKeyConstants.Permissions.ServerManagement))
{
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
$"{TestApiPath}/me/is-admin",
tester.PayTester.HttpClient));
}
}
public async Task<T> TestApiAgainstAccessToken<T>(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<T>(rawJson);
}
}
}

View file

@ -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<StoreData[]>(results["access_token"],
$"api/test/me/stores",
$"{TestApiPath}/me/stores",
tester.PayTester.HttpClient);
Assert.NotEmpty(stores);
Assert.True(await TestApiAgainstAccessToken<bool>(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<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<StoreData[]>(results["access_token"],
$"api/test/me/stores",
$"{TestApiPath}/me/stores",
tester.PayTester.HttpClient);
});
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(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<string>(accessToken, "api/test/me/id",
await TestApiAgainstAccessToken<string>(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<StoreData[]>(accessToken, "api/test/me/stores",
await TestApiAgainstAccessToken<StoreData[]>(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<bool>(accessToken,
$"api/test/me/stores/{testAccount.StoreId}/can-edit",
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit",
tester.PayTester.HttpClient));
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
$"api/test/me/is-admin",
$"{TestApiPath}/me/is-admin",
tester.PayTester.HttpClient));
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(accessToken, $"api/test/me/stores/{secondUser.StoreId}/can-edit",
await TestApiAgainstAccessToken<bool>(accessToken, $"{TestApiPath}/me/stores/{secondUser.StoreId}/can-edit",
tester.PayTester.HttpClient);
});
}

View file

@ -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()
{

View file

@ -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<IActionResult> APIKeys()
{
return View(new ApiKeysViewModel()
{
ApiKeyDatas = await _apiKeyRepository.GetKeys(new APIKeyRepository.APIKeyQuery()
{
UserId = new[] {_userManager.GetUserId(User)}
})
});
}
[HttpGet]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<string>();
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<IActionResult> 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! <code>{key.Id}</code>"
});
return RedirectToAction("APIKeys", new { key = key.Id});
default: return View(viewModel);
}
}
[HttpPost]
public async Task<IActionResult> 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! <code>{key.Id}</code>"
});
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<APIKeyData> 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<string> GetPermissionsFromViewModel(AddApiKeyViewModel viewModel)
{
var permissions = new List<string>();
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<T> SetViewModelValues<T>(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<string> SpecificStores { get; set; } = new List<string>();
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<string>());
}
}
}
public class ApiKeysViewModel
{
public List<APIKeyData> ApiKeyDatas { get; set; }
}
}
}

View file

@ -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;
}

View file

@ -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
{
/// <summary>
/// this controller serves as a testing endpoint for our api key unit tests
/// </summary>
[Route("api/test/apikey")]
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public class TestApiKeyController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly StoreRepository _storeRepository;
public TestApiKeyController(UserManager<ApplicationUser> userManager, StoreRepository storeRepository)
{
_userManager = userManager;
_storeRepository = storeRepository;
}
[HttpGet("me/id")]
public string GetCurrentUserId()
{
return _userManager.GetUserId(User);
}
[HttpGet("me")]
public async Task<ApplicationUser> 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<StoreData[]> 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;
}
}
}

View file

@ -13,15 +13,15 @@ namespace BTCPayServer.Controllers.RestApi
/// <summary>
/// this controller serves as a testing endpoint for our OpenId unit tests
/// </summary>
[Route("api/[controller]")]
[Route("api/test/openid")]
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.OpenId)]
public class TestController : ControllerBase
public class TestOpenIdController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly StoreRepository _storeRepository;
public TestController(UserManager<ApplicationUser> userManager, StoreRepository storeRepository)
public TestOpenIdController(UserManager<ApplicationUser> 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)]

View file

@ -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<PaymentRequestController>();
// Add application services.
services.AddSingleton<EmailSenderFactory>();
// bundling
services.AddBtcPayServerAuthenticationSchemes(configuration);
services.AddAPIKeyAuthentication();
services.AddBtcPayServerAuthenticationSchemes();
services.AddAuthorization(o => o.AddBTCPayPolicies());
// bundling
services.AddSingleton<IBundleProvider, ResourceBundleProvider>();
services.AddTransient<BundleOptions>(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)

View file

@ -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<APIKeyAuthenticationOptions>
{
private readonly APIKeyRepository _apiKeyRepository;
private readonly IOptionsMonitor<IdentityOptions> _identityOptions;
public APIKeyAuthenticationHandler(
APIKeyRepository apiKeyRepository,
IOptionsMonitor<IdentityOptions> identityOptions,
IOptionsMonitor<APIKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
_apiKeyRepository = apiKeyRepository;
_identityOptions = identityOptions;
}
protected override async Task<AuthenticateResult> 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<Claim> claims = new List<Claim>();
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));
}
}
}

View file

@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Authentication;
namespace BTCPayServer.Security.Bitpay
{
public class APIKeyAuthenticationOptions : AuthenticationSchemeOptions
{
}
}

View file

@ -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<PolicyRequirement>
{
private readonly HttpContext _HttpContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly StoreRepository _storeRepository;
public APIKeyAuthorizationHandler(IHttpContextAccessor httpContextAccessor,
UserManager<ApplicationUser> 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);
}
}
}
}

View file

@ -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<string, (string Title, string Description)> PermissionDescriptions = new Dictionary<string, (string Title, string Description)>()
{
{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<string> ExtractStorePermissionsIds(IEnumerable<string> permissions) => permissions
.Where(s => s.StartsWith($"{nameof(StoreManagement)}:", StringComparison.InvariantCulture))
.Select(s => s.Split(":")[1]);
}
}
}

View file

@ -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<StoreData[]> GetStores(this ClaimsPrincipal claimsPrincipal,
UserManager<ApplicationUser> 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<APIKeyAuthenticationOptions, APIKeyAuthenticationHandler>(AuthenticationSchemes.ApiKey,
o => { });
return builder;
}
public static IServiceCollection AddAPIKeyAuthentication(this IServiceCollection serviceCollection)
{
serviceCollection.AddSingleton<APIKeyRepository>();
serviceCollection.AddScoped<IAuthorizationHandler, APIKeyAuthorizationHandler>();
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)));
}
}
}

View file

@ -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<APIKeyData> 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<List<APIKeyData>> 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; }
}
}
}

View file

@ -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";
}
}

View file

@ -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();
}
}

View file

@ -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";

View file

@ -78,12 +78,12 @@ namespace BTCPayServer.Services.Stores
}
}
public async Task<StoreData[]> GetStoresByUserId(string userId)
public async Task<StoreData[]> GetStoresByUserId(string userId, IEnumerable<string> 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 =>

View file

@ -0,0 +1,50 @@
@model BTCPayServer.Controllers.ManageController.ApiKeysViewModel
@{
ViewData.SetActivePageAndTitle(ManageNavPages.APIKeys, "Manage your API Keys");
}
<partial name="_StatusMessage"/>
<h4>API Keys</h4>
<table class="table table-lg">
<thead>
<tr>
<th >Key</th>
<th >Permissions</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var keyData in Model.ApiKeyDatas)
{
<tr>
<td>@keyData.Id</td>
<td>
@if (string.IsNullOrEmpty(keyData.Permissions))
{
<span>No permissions</span>
}
else
{
<span>@string.Join(", ", keyData.GetPermissions())</span>
}
</td>
<td class="text-right">
<a asp-action="RemoveAPIKey" asp-route-id="@keyData.Id">Remove</a>
</td>
</tr>
}
@if (!Model.ApiKeyDatas.Any())
{
<tr>
<td colspan="2" class="text-center h5 py-2">
No API keys
</td>
</tr>
}
<tr class="bg-gray">
<td colspan="3">
<a class="btn btn-primary" asp-action="AddApiKey" id="AddApiKey">Generate new key</a>
</td>
</tr>
</tbody>
</table>

View file

@ -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;
}
}
<h4>@ViewData["Title"]</h4>
<partial name="_StatusMessage"/>
<p >
Generate a new api key to use BTCPay through its API.
</p>
<div class="row">
<div class="col-md-12">
<form method="post" asp-action="AddApiKey" class="list-group">
<input type="hidden" asp-for="StoreMode" value="@Model.StoreMode"/>
<div asp-validation-summary="All" class="text-danger"></div>
@if (Model.IsServerAdmin)
{
<div class="list-group-item form-group">
<input asp-for="ServerManagementPermission" class="form-check-inline"/>
<label asp-for="ServerManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.ServerManagement)</label>
<span asp-validation-for="ServerManagementPermission" class="text-danger"></span>
<p>@GetDescription(APIKeyConstants.Permissions.ServerManagement).</p>
</div>
}
@if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores)
{
<div class="list-group-item form-group">
@Html.CheckBoxFor(model => model.StoreManagementPermission, new Dictionary<string, string>() {{"class", "form-check-inline"}})
<label asp-for="StoreManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.StoreManagement)</label>
<span asp-validation-for="StoreManagementPermission" class="text-danger"></span>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement).</p>
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to specific stores instead</button>
</div>
}
else if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.Specific)
{
<div class="list-group-item p-0 border-0 mb-2">
<li class="list-group-item ">
<h5 class="mb-1">@GetTitle(APIKeyConstants.Permissions.StoreManagement + ":")</h5>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement + ":").</p>
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to all stores instead</button>
</li>
@if (!Model.Stores.Any())
{
<li class="list-group-item alert-warning">
You currently have no stores configured.
</li>
}
@for (var index = 0; index < Model.SpecificStores.Count; index++)
{
<div class="list-group-item transaction-output-form p-0 pl-lg-2">
<div class="row">
<div class="col-sm-12 col-md-12 col-lg-10 py-2 ">
<div class="form-group my-0">
@if (Model.SpecificStores[index] == null)
{
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(Model.Stores.Where(data => !Model.SpecificStores.Contains(data.Id)), nameof(StoreData.Id), nameof(StoreData.StoreName)))"></select>
}
else
{
var store = Model.Stores.SingleOrDefault(data => data.Id == Model.SpecificStores[index]);
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(new[] {store}, nameof(StoreData.Id), nameof(StoreData.StoreName), store.Id))"></select>
}
<span asp-validation-for="SpecificStores[index]" class="text-danger"></span>
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-2 pull-right">
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-block d-lg-none d-xl-none btn btn-danger mb-2 ml-2">
Remove
</button>
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-none d-lg-block remove-btn text-decoration-none h-100 align-middle btn text-danger btn-link fa fa-times rounded-0 pull-right">
</button>
</div>
</div>
</div>
}
@if (Model.SpecificStores.Count < Model.Stores.Length)
{
<div class="list-group-item">
<button type="submit" name="command" value="add-store" class="ml-1 btn btn-secondary">Add another store </button>
</div>
}
</div>
}
<button type="submit" class="btn btn-primary" id="Generate">Generate API Key</button>
</form>
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
<style>
.remove-btn{
font-size: 1.5rem;
border-radius: 0;
}
.remove-btn:hover{
background-color: #CCCCCC;
}
</style>
}

View file

@ -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;
}
}
<partial name="_StatusMessage"/>
<form method="post" asp-action="AuthorizeAPIKey">
<input type="hidden" asp-for="Permissions" value="@Model.Permissions"/>
<input type="hidden" asp-for="Strict" value="@Model.Strict"/>
<input type="hidden" asp-for="ApplicationName" value="@Model.ApplicationName"/>
<input type="hidden" asp-for="SelectiveStores" value="@Model.SelectiveStores"/>
<section>
<div class="card container">
<div class="row">
<div class="col-lg-12 section-heading">
<h2>Authorization Request</h2>
<hr class="primary">
<p class="mb-1">@(Model.ApplicationName ?? "An application") is requesting access to your account.</p>
</div>
</div>
<div class="row">
<div class="col-lg-12 list-group px-2">
<div asp-validation-summary="All" class="text-danger"></div>
@if (Model.PermissionsFormatted.Contains(APIKeyConstants.Permissions.ServerManagement) && (Model.IsServerAdmin || Model.Strict))
{
<div class="list-group-item form-group">
<input asp-for="ServerManagementPermission" class="form-check-inline" readonly="@(Model.Strict || !Model.IsServerAdmin)"/>
<label asp-for="ServerManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.ServerManagement)</label>
@if (!Model.IsServerAdmin)
{
<span class="text-danger">
The server management permission is being requested but your account is not an administrator
</span>
}
<span asp-validation-for="ServerManagementPermission" class="text-danger"></span>
<p>@GetDescription(APIKeyConstants.Permissions.ServerManagement).</p>
</div>
}
@if (Model.PermissionsFormatted.Contains(APIKeyConstants.Permissions.StoreManagement))
{
@if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores)
{
<div class="list-group-item form-group">
<input type="checkbox" asp-for="StoreManagementPermission" class="form-check-inline" readonly="@Model.Strict"/>
<label asp-for="StoreManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.StoreManagement)</label>
<span asp-validation-for="StoreManagementPermission" class="text-danger"></span>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement).</p>
@if (Model.SelectiveStores)
{
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to specific stores instead</button>
}
</div>
}
else if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.Specific)
{
<div class="list-group-item p-0 border-0 mb-2">
<li class="list-group-item">
<h5 class="mb-1">@GetTitle(APIKeyConstants.Permissions.StoreManagement + ":")</h5>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement + ":").</p>
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to all stores instead</button>
</li>
@if (!Model.Stores.Any())
{
<li class="list-group-item alert-warning">
You currently have no stores configured.
</li>
}
@for (var index = 0; index < Model.SpecificStores.Count; index++)
{
<div class="list-group-item transaction-output-form p-0 pl-lg-2">
<div class="row">
<div class="col-sm-12 col-md-12 col-lg-10 py-2 ">
<div class="form-group my-0">
@if (Model.SpecificStores[index] == null)
{
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(Model.Stores.Where(data => !Model.SpecificStores.Contains(data.Id)), nameof(StoreData.Id), nameof(StoreData.StoreName)))"></select>
}
else
{
var store = Model.Stores.SingleOrDefault(data => data.Id == Model.SpecificStores[index]);
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(new[] {store}, nameof(StoreData.Id), nameof(StoreData.StoreName), store.Id))"></select>
}
<span asp-validation-for="SpecificStores[index]" class="text-danger"></span>
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-2 pull-right">
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-block d-lg-none d-xl-none btn btn-danger mb-2 ml-2">
Remove
</button>
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-none d-lg-block remove-btn text-decoration-none h-100 align-middle btn text-danger btn-link fa fa-times rounded-0 pull-right">
</button>
</div>
</div>
</div>
}
@if (Model.SpecificStores.Count < Model.Stores.Length)
{
<div class="list-group-item">
<button type="submit" name="command" value="add-store" class="ml-1 btn btn-secondary">Add another store </button>
</div>
}
</div>
}
}
</div>
</div>
<div class="row my-2">
<div class="col-lg-12 text-center">
<div class="btn-group">
<button class="btn btn-primary" name="command" id="consent-yes" type="submit" value="Yes">Authorize app</button>
</div>
<button class="btn btn-secondary" id="consent-no" name="command" type="submit" value="No">Cancel</button>
</div>
</div>
</div>
</section>
</form>

View file

@ -7,6 +7,6 @@ namespace BTCPayServer.Views.Manage
{
public enum ManageNavPages
{
Index, ChangePassword, TwoFactorAuthentication, U2F
Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys
}
}

View file

@ -1,9 +1,10 @@
@inject SignInManager<ApplicationUser> SignInManager
<div class="nav flex-column nav-pills">
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.Index)" asp-action="Index">Profile</a>
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.ChangePassword)" asp-action="ChangePassword" id="ChangePassword">Password</a>
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-action="TwoFactorAuthentication">Two-factor authentication</a>
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.Index.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Index)" asp-action="Index">Profile</a>
<a id="@ManageNavPages.ChangePassword.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.ChangePassword)" asp-action="ChangePassword">Password</a>
<a id="@ManageNavPages.TwoFactorAuthentication.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-action="TwoFactorAuthentication">Two-factor authentication</a>
<a id="@ManageNavPages.U2F.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-action="APIKeys">API Keys</a>
</div>