Add API Keys Application identifier

This lets the authorize api key screen redirect to the defined url  and provide it with the user id, permissions granted and the key.

This also allows apps to match existing api keys generated for it specifically using the application identifier, and if matched, presented with a confirmation page before redirection.
This commit is contained in:
Kukks 2020-02-28 19:42:17 +01:00
parent cf7c5102fc
commit 7ca74aeea7
5 changed files with 118 additions and 9 deletions

View file

@ -15,6 +15,7 @@ namespace BTCPayServer.Data
[MaxLength(50)] public string StoreId { get; set; }
[MaxLength(50)] public string UserId { get; set; }
public string ApplicationIdentifier { get; set; }
public APIKeyType Type { get; set; } = APIKeyType.Legacy;
@ -43,6 +44,8 @@ namespace BTCPayServer.Data
public class APIKeyBlob
{
public string[] Permissions { get; set; }
public string ApplicationIdentifier { get; set; }
public string ApplicationAuthority { get; set; }
}

View file

@ -128,6 +128,7 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("change-store-mode", s.Driver.PageSource);
s.Driver.FindElement(By.Id("consent-yes")).Click();
var url = s.Driver.Url;
Assert.StartsWith("https://local.local/callback", url);
IEnumerable<KeyValuePair<string, string>> results = url.Split("?").Last().Split("&")
.Select(s1 => new KeyValuePair<string, string>(s1.Split("=")[0], s1.Split("=")[1]));
@ -151,6 +152,7 @@ namespace BTCPayServer.Tests
Assert.Contains("change-store-mode", s.Driver.PageSource);
s.Driver.FindElement(By.Id("consent-yes")).Click();
url = s.Driver.Url;
Assert.StartsWith("https://local.local/callback", url);
results = url.Split("?").Last().Split("&")
.Select(s1 => new KeyValuePair<string, string>(s1.Split("=")[0], s1.Split("=")[1]));

View file

@ -7,7 +7,9 @@ using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Security.GreenField;
using ExchangeSharp;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitcoin.DataEncoders;
@ -77,10 +79,22 @@ namespace BTCPayServer.Controllers
return View("AddApiKey", await SetViewModelValues(new AddApiKeyViewModel()));
}
/// <param name="permissions">The permissions to request</param>
/// <param name="applicationName">The name of your application</param>
/// <param name="redirect">The URl to redirect to after the user consents, with the query paramters appended to it: permissions, user-id, api-key. If not specified, user is redirect to their API Key list.</param>
/// <param name="strict">If permissions are specified, and strict is set to false, it will allow the user to reject some of permissions the application is requesting.</param>
/// <param name="selectiveStores">If the application is requesting the CanModifyStoreSettings permission and selectiveStores is set to true, this allows the user to only grant permissions to selected stores under the user's control.</param>
/// <param name="applicationIdentifier">If specified, BTCPay will check if there is an existing API key stored associated with the user that also has this application identifer, redirect host AND the permissions required match(takes selectiveStores and strict into account). applicationIdentifier is ignored if redirect is not specified.</param>
// [OpenApiTags("Authorization")]
// [OpenApiOperation("Authorize User",
// "Redirect the browser to this endpoint to request the user to generate an api-key with specific permissions")]
// [SwaggerResponse(StatusCodes.Status307TemporaryRedirect, null,
// Description = "Redirects to the specified url with query string values for api-key, permissions, and user-id upon consent")]
// [IncludeInOpenApiDocs]
[HttpGet("~/api-keys/authorize")]
public async Task<IActionResult> AuthorizeAPIKey(string[] permissions, string applicationName = null,
bool strict = true, bool selectiveStores = false)
public async Task<IActionResult> AuthorizeAPIKey( string[] permissions, string applicationName = null, Uri redirect = null,
bool strict = true, bool selectiveStores = false, string applicationIdentifier = null)
{
if (!_btcPayServerEnvironment.IsSecure)
{
@ -95,13 +109,63 @@ namespace BTCPayServer.Controllers
permissions ??= Array.Empty<string>();
var parsedPermissions = Permission.ToPermissions(permissions).GroupBy(permission => permission.Policy);
if (!string.IsNullOrEmpty(applicationIdentifier) && redirect != null)
{
//check if there is an app identifier that matches and belongs to the current user
var keys = await _apiKeyRepository.GetKeys(new APIKeyRepository.APIKeyQuery()
{
UserId = new[] {_userManager.GetUserId(User)}
});
if (keys.Any())
{
foreach (var key in keys)
{
var blob = key.GetBlob();
if (blob.ApplicationIdentifier != applicationIdentifier ||
blob.ApplicationAuthority != redirect.Authority)
{
continue;
}
//matched the identifier and authority, but we need to check if what the app is requesting in terms of permissions is enough
var alreadyPresentPermissions = Permission.ToPermissions(blob.Permissions);
var selectiveStorePermissions =
alreadyPresentPermissions.Where(permission => !string.IsNullOrEmpty(permission.Scope));
//if application is requesting the store management permission without the selective option but the existing key only has selective stores, skip
if(parsedPermissions)
if (permissions.Contains(APIKeyConstants.Permissions.StoreManagement) && !selectiveStores && selectiveStorePermissions.Any())
{
continue;
}
if (strict && permissions.Any(s => !blob.Permissions.Contains(s)))
{
continue;
}
//we have a key that is sufficient, redirect to a page to confirm that it's ok to provide this key to the app.
return View("Confirm",
new ConfirmModel()
{
Title = $"Are you sure about exposing your API Key to {redirect}?",
Description = $"You've previously generated this API Key ({key.Id}) specifically for {applicationName}",
ActionUrl = GetRedirectToApplicationUrl(redirect, key),
ButtonClass = "btn-secondary",
Action = "Confirm"
});
}
}
}
var vm = await SetViewModelValues(new AuthorizeApiKeysViewModel()
{
RedirectUrl = redirect,
Label = applicationName,
ApplicationName = applicationName,
SelectiveStores = selectiveStores,
Strict = strict,
Permissions = string.Join(';', parsedPermissions.SelectMany(grouping => grouping.Select(permission => permission.ToString())))
Permissions = string.Join(';', parsedPermissions.SelectMany(grouping => grouping.Select(permission => permission.ToString()))),
ApplicationIdentifier = applicationIdentifier
});
AdjustVMForAuthorization(vm);
@ -183,7 +247,13 @@ namespace BTCPayServer.Controllers
case "no":
return RedirectToAction("APIKeys");
case "yes":
var key = await CreateKey(viewModel);
var key = await CreateKey(viewModel, viewModel.ApplicationIdentifier);
if (viewModel.RedirectUrl != null)
{
return Redirect(GetRedirectToApplicationUrl(viewModel.RedirectUrl, key));
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
@ -195,6 +265,20 @@ namespace BTCPayServer.Controllers
}
}
private string GetRedirectToApplicationUrl(Uri redirect, APIKeyData key)
{
var uri = new UriBuilder(redirect);
var permissions = key.GetBlob().Permissions;
uri.AppendPayloadToQuery(new Dictionary<string, object>()
{
{"api-key", key.Id}, {"permissions",permissions}, {"user-id", key.UserId}
});
//uri builder has bug around string[] params
return uri.Uri.ToStringInvariant().Replace("permissions=System.String%5B%5D",
string.Join("&", permissions.Select(s1 => $"permissions={s1}")), StringComparison.InvariantCulture);
}
[HttpPost]
public async Task<IActionResult> AddApiKey(AddApiKeyViewModel viewModel)
{
@ -268,14 +352,15 @@ namespace BTCPayServer.Controllers
return null;
}
private async Task<APIKeyData> CreateKey(AddApiKeyViewModel viewModel)
private async Task<APIKeyData> CreateKey(AddApiKeyViewModel viewModel, string appIdentifier = null)
{
var key = new APIKeyData()
{
Id = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20)),
Type = APIKeyType.Permanent,
UserId = _userManager.GetUserId(User),
Label = viewModel.Label
Label = viewModel.Label,
ApplicationIdentifier = appIdentifier
};
key.SetBlob(new APIKeyBlob()
{
@ -402,6 +487,8 @@ namespace BTCPayServer.Controllers
public class AuthorizeApiKeysViewModel : AddApiKeyViewModel
{
public string ApplicationName { get; set; }
public string ApplicationIdentifier { get; set; }
public Uri RedirectUrl { get; set; }
public bool Strict { get; set; }
public bool SelectiveStores { get; set; }
public string Permissions { get; set; }

View file

@ -30,9 +30,18 @@ namespace BTCPayServer.Security.GreenField
using (var context = _applicationDbContextFactory.CreateContext())
{
var queryable = context.ApiKeys.AsQueryable();
if (query?.UserId != null && query.UserId.Any())
if (query != null)
{
queryable = queryable.Where(data => query.UserId.Contains(data.UserId));
if (query.UserId != null && query.UserId.Any())
{
queryable = queryable.Where(data => query.UserId.Contains(data.UserId));
}
if (query.ApplicationIdentifier != null && query.ApplicationIdentifier.Any())
{
queryable = queryable.Where(data =>
query.ApplicationIdentifier.Contains(data.ApplicationIdentifier));
}
}
return await queryable.ToListAsync();

View file

@ -11,10 +11,12 @@
<partial name="_StatusMessage"/>
<form method="post" asp-action="AuthorizeAPIKey">
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
<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"/>
<input type="hidden" asp-for="ApplicationIdentifier" value="@Model.ApplicationIdentifier"/>
<section>
<div class="card container">
<div class="row">
@ -22,6 +24,12 @@
<h2>Authorization Request</h2>
<hr class="primary">
<p class="mb-1">@(Model.ApplicationName ?? "An application") is requesting access to your account.</p>
@if (Model.RedirectUrl != null)
{
<p class="mb-1 alert alert-info">
If authorized, the generated API key will be provided to <strong>@Model.RedirectUrl.AbsoluteUri</strong>
</p>
}
</div>
</div>
<div class="row">