mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
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:
parent
a3e7729c52
commit
fa51180dfa
29 changed files with 1502 additions and 44 deletions
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -30,5 +30,6 @@ namespace BTCPayServer.Data
|
|||
}
|
||||
|
||||
public List<U2FDevice> U2FDevices { get; set; }
|
||||
public List<APIKeyData> APIKeys { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
74
BTCPayServer.Data/Migrations/20200119130108_ExtendApiKeys.cs
Normal file
74
BTCPayServer.Data/Migrations/20200119130108_ExtendApiKeys.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 =>
|
||||
|
|
|
@ -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";
|
||||
|
|
300
BTCPayServer.Tests/ApiKeysTests.cs
Normal file
300
BTCPayServer.Tests/ApiKeysTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
{
|
||||
|
|
295
BTCPayServer/Controllers/ManageController.APIKeys.cs
Normal file
295
BTCPayServer/Controllers/ManageController.APIKeys.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
73
BTCPayServer/Controllers/RestApi/TestApiKeyController.cs
Normal file
73
BTCPayServer/Controllers/RestApi/TestApiKeyController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)]
|
|
@ -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)
|
||||
|
|
56
BTCPayServer/Security/APIKeys/APIKeyAuthenticationHandler.cs
Normal file
56
BTCPayServer/Security/APIKeys/APIKeyAuthenticationHandler.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace BTCPayServer.Security.Bitpay
|
||||
{
|
||||
public class APIKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
}
|
||||
}
|
84
BTCPayServer/Security/APIKeys/APIKeyAuthorizationHandler.cs
Normal file
84
BTCPayServer/Security/APIKeys/APIKeyAuthorizationHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
36
BTCPayServer/Security/APIKeys/APIKeyConstants.cs
Normal file
36
BTCPayServer/Security/APIKeys/APIKeyConstants.cs
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
75
BTCPayServer/Security/APIKeys/APIKeyExtensions.cs
Normal file
75
BTCPayServer/Security/APIKeys/APIKeyExtensions.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
}
|
72
BTCPayServer/Security/APIKeys/APIKeyRepository.cs
Normal file
72
BTCPayServer/Security/APIKeys/APIKeyRepository.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 =>
|
||||
|
|
50
BTCPayServer/Views/Manage/APIKeys.cshtml
Normal file
50
BTCPayServer/Views/Manage/APIKeys.cshtml
Normal 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>
|
120
BTCPayServer/Views/Manage/AddApiKey.cshtml
Normal file
120
BTCPayServer/Views/Manage/AddApiKey.cshtml
Normal 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>
|
||||
}
|
137
BTCPayServer/Views/Manage/AuthorizeAPIKey.cshtml
Normal file
137
BTCPayServer/Views/Manage/AuthorizeAPIKey.cshtml
Normal 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>
|
|
@ -7,6 +7,6 @@ namespace BTCPayServer.Views.Manage
|
|||
{
|
||||
public enum ManageNavPages
|
||||
{
|
||||
Index, ChangePassword, TwoFactorAuthentication, U2F
|
||||
Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue