diff --git a/BTCPayServer.Data/Data/APIKeyData.cs b/BTCPayServer.Data/Data/APIKeyData.cs index db02b20cd..187d03699 100644 --- a/BTCPayServer.Data/Data/APIKeyData.cs +++ b/BTCPayServer.Data/Data/APIKeyData.cs @@ -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; } } diff --git a/BTCPayServer.Tests/ApiKeysTests.cs b/BTCPayServer.Tests/ApiKeysTests.cs index dd4cb8972..01df4e2cd 100644 --- a/BTCPayServer.Tests/ApiKeysTests.cs +++ b/BTCPayServer.Tests/ApiKeysTests.cs @@ -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> results = url.Split("?").Last().Split("&") .Select(s1 => new KeyValuePair(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(s1.Split("=")[0], s1.Split("=")[1])); diff --git a/BTCPayServer/Controllers/ManageController.APIKeys.cs b/BTCPayServer/Controllers/ManageController.APIKeys.cs index ba5ce104e..91bce626f 100644 --- a/BTCPayServer/Controllers/ManageController.APIKeys.cs +++ b/BTCPayServer/Controllers/ManageController.APIKeys.cs @@ -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())); } - + + /// The permissions to request + /// The name of your application + /// 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. + /// If permissions are specified, and strict is set to false, it will allow the user to reject some of permissions the application is requesting. + /// 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. + /// 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. + // [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 AuthorizeAPIKey(string[] permissions, string applicationName = null, - bool strict = true, bool selectiveStores = false) + public async Task 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(); 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() + { + {"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 AddApiKey(AddApiKeyViewModel viewModel) { @@ -268,14 +352,15 @@ namespace BTCPayServer.Controllers return null; } - private async Task CreateKey(AddApiKeyViewModel viewModel) + private async Task 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; } diff --git a/BTCPayServer/Security/GreenField/APIKeyRepository.cs b/BTCPayServer/Security/GreenField/APIKeyRepository.cs index 446309aa3..5ffe4782b 100644 --- a/BTCPayServer/Security/GreenField/APIKeyRepository.cs +++ b/BTCPayServer/Security/GreenField/APIKeyRepository.cs @@ -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(); diff --git a/BTCPayServer/Views/Manage/AuthorizeAPIKey.cshtml b/BTCPayServer/Views/Manage/AuthorizeAPIKey.cshtml index b835b8f76..5d5b1e6d9 100644 --- a/BTCPayServer/Views/Manage/AuthorizeAPIKey.cshtml +++ b/BTCPayServer/Views/Manage/AuthorizeAPIKey.cshtml @@ -11,10 +11,12 @@
+ +
@@ -22,6 +24,12 @@

Authorization Request


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

+ @if (Model.RedirectUrl != null) + { +

+ If authorized, the generated API key will be provided to @Model.RedirectUrl.AbsoluteUri +

+ }