Better handling of confirm case (existing API key)

This commit is contained in:
Dennis Reimann 2022-06-21 11:42:58 +02:00 committed by Andrew Camilleri
parent 209cff8888
commit 2b9cb4a257
4 changed files with 180 additions and 224 deletions

View file

@ -49,7 +49,6 @@ namespace BTCPayServer.Tests
s.GoToProfile(ManageNavPages.APIKeys);
s.Driver.FindElement(By.Id("AddApiKey")).Click();
TestLogs.LogInformation("Checking admin permissions");
//not an admin, so this permission should not show
Assert.DoesNotContain("btcpay.server.canmodifyserversettings", s.Driver.PageSource);
await user.MakeAdmin();
@ -66,7 +65,6 @@ namespace BTCPayServer.Tests
s.Driver.SetCheckbox(By.Id("btcpay.user.canviewprofile"), true);
s.Driver.FindElement(By.Id("Generate")).Click();
var superApiKey = s.FindAlertMessage().FindElement(By.TagName("code")).Text;
TestLogs.LogInformation("Checking super admin key");
//this api key has access to everything
await TestApiAgainstAccessToken(superApiKey, tester, user, Policies.CanModifyServerSettings, Policies.CanModifyStoreSettings, Policies.CanViewProfile);
@ -75,9 +73,6 @@ namespace BTCPayServer.Tests
s.Driver.SetCheckbox(By.Id("btcpay.server.canmodifyserversettings"), true);
s.Driver.FindElement(By.Id("Generate")).Click();
var serverOnlyApiKey = s.FindAlertMessage().FindElement(By.TagName("code")).Text;
TestLogs.LogInformation("Checking CanModifyServerSettings permissions");
await TestApiAgainstAccessToken(serverOnlyApiKey, tester, user,
Policies.CanModifyServerSettings);
@ -85,9 +80,6 @@ namespace BTCPayServer.Tests
s.Driver.SetCheckbox(By.Id("btcpay.store.canmodifystoresettings"), true);
s.Driver.FindElement(By.Id("Generate")).Click();
var allStoreOnlyApiKey = s.FindAlertMessage().FindElement(By.TagName("code")).Text;
TestLogs.LogInformation("Checking CanModifyStoreSettings permissions");
await TestApiAgainstAccessToken(allStoreOnlyApiKey, tester, user,
Policies.CanModifyStoreSettings);
@ -104,21 +96,13 @@ namespace BTCPayServer.Tests
option.Click();
s.Driver.WaitForAndClick(By.Id("Generate"));
var selectiveStoreApiKey = s.FindAlertMessage().FindElement(By.TagName("code")).Text;
TestLogs.LogInformation("Checking CanModifyStoreSettings with StoreId permissions");
await TestApiAgainstAccessToken(selectiveStoreApiKey, tester, user,
Permission.Create(Policies.CanModifyStoreSettings, storeId).ToString());
TestLogs.LogInformation("Adding API key for no permissions");
s.Driver.WaitForAndClick(By.Id("AddApiKey"));
TestLogs.LogInformation("Generating API key for no permissions");
s.Driver.WaitForAndClick(By.Id("Generate"));
var noPermissionsApiKey = s.FindAlertMessage().FindElement(By.TagName("code")).Text;
TestLogs.LogInformation($"Checking no permissions: {noPermissionsApiKey}");
await TestApiAgainstAccessToken(noPermissionsApiKey, tester, user);
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>("incorrect key", $"{TestApiPath}/me/id",
@ -142,7 +126,6 @@ namespace BTCPayServer.Tests
new[] { Policies.CanModifyServerSettings }, applicationDetails: (appidentifier, new Uri(callbackUrl))).ToString();
// No upfront store selection with only server settings
TestLogs.LogInformation($"Going to auth URL {authUrl}");
s.GoToUrl(authUrl);
Assert.Contains(appidentifier, s.Driver.PageSource);
Assert.False(s.Driver.FindElement(By.Id("SpecificStores")).Displayed);
@ -150,7 +133,6 @@ namespace BTCPayServer.Tests
// Now with store settings
authUrl = BTCPayServerClient.GenerateAuthorizeUri(s.ServerUri,
new[] { Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings }, applicationDetails: (appidentifier, new Uri(callbackUrl))).ToString();
TestLogs.LogInformation($"Going to auth URL {authUrl}");
s.GoToUrl(authUrl);
Assert.Contains(appidentifier, s.Driver.PageSource);
@ -165,26 +147,18 @@ namespace BTCPayServer.Tests
Assert.Equal("true", s.Driver.FindElement(By.Id("btcpay.server.canmodifyserversettings")).GetAttribute("value").ToLowerInvariant());
Assert.DoesNotContain("change-store-mode", s.Driver.PageSource);
TestLogs.LogInformation("Going to callback URL");
s.Driver.WaitForAndClick(By.Id("consent-yes"));
Assert.Equal(callbackUrl, s.Driver.Url);
TestLogs.LogInformation("On callback URL");
var apiKeyRepo = s.Server.PayTester.GetService<APIKeyRepository>();
var accessToken = GetAccessTokenFromCallbackResult(s.Driver);
TestLogs.LogInformation($"Access token: {accessToken}");
await TestApiAgainstAccessToken(accessToken, tester, user,
(await apiKeyRepo.GetKey(accessToken)).GetBlob().Permissions);
authUrl = BTCPayServerClient.GenerateAuthorizeUri(s.ServerUri,
new[] { Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings }, false, true, applicationDetails: (null, new Uri(callbackUrl))).ToString();
TestLogs.LogInformation($"Going to auth URL 2 {authUrl}");
s.GoToUrl(authUrl);
TestLogs.LogInformation("On auth URL 2");
Assert.DoesNotContain("kukksappname", s.Driver.PageSource);
// Select a store
@ -198,16 +172,10 @@ namespace BTCPayServer.Tests
Assert.Equal("true", s.Driver.FindElement(By.Id("btcpay.server.canmodifyserversettings")).GetAttribute("value").ToLowerInvariant());
s.Driver.SetCheckbox(By.Id("btcpay.server.canmodifyserversettings"), false);
TestLogs.LogInformation("Going to callback URL 2");
s.Driver.WaitForAndClick(By.Id("consent-yes"));
Assert.Equal(callbackUrl, s.Driver.Url);
TestLogs.LogInformation("On callback URL 2");
accessToken = GetAccessTokenFromCallbackResult(s.Driver);
TestLogs.LogInformation($"Access token: {accessToken}");
TestLogs.LogInformation("Checking authorized permissions");
await TestApiAgainstAccessToken(accessToken, tester, user,
(await apiKeyRepo.GetKey(accessToken)).GetBlob().Permissions);
@ -217,21 +185,17 @@ namespace BTCPayServer.Tests
new[] { Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings }, false, true, (appidentifier, new Uri(callbackUrl))).ToString();
//if it's the same, go to the confirm page
TestLogs.LogInformation($"Going to auth URL 3 {authUrl}");
s.GoToUrl(authUrl);
TestLogs.LogInformation("On auth URL 3");
Assert.Contains("previously generated the API Key", s.Driver.PageSource);
s.Driver.WaitForAndClick(By.Id("continue"));
TestLogs.LogInformation("Going to callback URL 3");
Assert.Equal(callbackUrl, s.Driver.Url);
TestLogs.LogInformation("On callback URL 3");
//same app but different redirect = nono
authUrl = BTCPayServerClient.GenerateAuthorizeUri(s.ServerUri,
new[] { Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings }, false, true, (appidentifier, new Uri("https://international.local/callback"))).ToString();
TestLogs.LogInformation($"Going to auth URL 4 {authUrl}");
s.GoToUrl(authUrl);
TestLogs.LogInformation("On auth URL 4");
Assert.DoesNotContain("previously generated the API Key", s.Driver.PageSource);
Assert.False(s.Driver.Url.StartsWith("https://international.com/callback"));
// Make sure we can check all permissions when not an admin
@ -240,14 +204,10 @@ namespace BTCPayServer.Tests
s.Logout();
s.GoToLogin();
s.LogIn(user.RegisterDetails.Email, user.RegisterDetails.Password);
TestLogs.LogInformation("Go to API Keys page");
s.GoToUrl("/account/apikeys");
TestLogs.LogInformation("On API Keys page");
s.Driver.WaitForAndClick(By.Id("AddApiKey"));
int checkedPermissionCount = s.Driver.FindElements(By.ClassName("form-check-input")).Count;
TestLogs.LogInformation($"Adding API key: {checkedPermissionCount} permissions");
s.Driver.ExecuteJavaScript("document.querySelectorAll('#Permissions .form-check-input').forEach(i => i.click())");
TestLogs.LogInformation($"Clicked {checkedPermissionCount}");
TestLogs.LogInformation("Generating API key");
s.Driver.WaitForAndClick(By.Id("Generate"));

View file

@ -97,84 +97,14 @@ namespace BTCPayServer.Controllers
permissions ??= Array.Empty<string>();
var requestPermissions = Permission.ToPermissions(permissions);
var requestPermissions = Permission.ToPermissions(permissions).ToList();
if (redirect?.IsAbsoluteUri is false)
{
redirect = null;
}
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) }
});
foreach (var key in keys)
{
var blob = key.GetBlob();
if (blob.ApplicationIdentifier != applicationIdentifier || blob.ApplicationAuthority != redirect.AbsoluteUri)
{
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)
.GroupBy(permission => permission.Policy);
var fail = false;
foreach (var permission in requestPermissions.GroupBy(permission => permission.Policy))
{
var presentPermission =
alreadyPresentPermissions.SingleOrDefault(grouping => permission.Key == grouping.Key);
if (strict && presentPermission == null)
{
fail = true;
break;
}
if (Policies.IsStorePolicy(permission.Key))
{
if (!selectiveStores && permission.Any(permission1 => !string.IsNullOrEmpty(permission1.Scope)))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message =
"Cannot request specific store permission when selectiveStores is not enable"
});
return RedirectToAction("APIKeys");
}
if (!selectiveStores && presentPermission.Any(permission1 => !string.IsNullOrEmpty(permission1.Scope)))
{
fail = true;
break;
}
}
}
if (fail)
{
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("ConfirmAPIKey", new AuthorizeApiKeysViewModel
{
ApiKey = key.Id,
RedirectUrl = redirect,
Label = applicationName,
ApplicationName = applicationName,
SelectiveStores = selectiveStores,
Strict = strict,
Permissions = string.Join(';', permissions),
ApplicationIdentifier = applicationIdentifier
});
}
}
var vm = await SetViewModelValues(new AuthorizeApiKeysViewModel
var vm = new AuthorizeApiKeysViewModel
{
RedirectUrl = redirect,
Label = applicationName,
@ -183,86 +113,27 @@ namespace BTCPayServer.Controllers
Strict = strict,
Permissions = string.Join(';', requestPermissions),
ApplicationIdentifier = applicationIdentifier
});
};
var existingApiKey = await CheckForMatchingApiKey(applicationIdentifier, redirect, requestPermissions, strict);
if (existingApiKey != null)
{
vm.ApiKey = existingApiKey.Id;
return View("ConfirmAPIKey", vm);
}
vm = await SetViewModelValues(vm);
AdjustVMForAuthorization(vm);
return View(vm);
}
private void AdjustVMForAuthorization(AuthorizeApiKeysViewModel vm)
{
var storeIds = vm.SpecificStores.ToArray();
var permissions = vm.Permissions?.Split(';') ?? Array.Empty<string>();
var permissionsWithStoreIDs = new List<string>();
vm.NeedsStorePermission = permissions.Any(Policies.IsStorePolicy) || !vm.Strict;
// Go over each permission and associated store IDs and join them
// so that permission for a specific store is parsed correctly
foreach (var permission in permissions)
{
if (!Policies.IsStorePolicy(permission) || storeIds.Length == 0)
{
permissionsWithStoreIDs.Add(permission);
}
else
{
foreach (var t in storeIds)
{
permissionsWithStoreIDs.Add($"{permission}:{t}");
}
}
}
var parsedPermissions = Permission.ToPermissions(permissionsWithStoreIDs.ToArray()).GroupBy(permission => permission.Policy);
for (var index = vm.PermissionValues.Count - 1; index >= 0; index--)
{
var permissionValue = vm.PermissionValues[index];
var wanted = parsedPermissions.SingleOrDefault(permission =>
permission.Key.Equals(permissionValue.Permission,
StringComparison.InvariantCultureIgnoreCase));
if (vm.Strict && !(wanted?.Any() ?? false))
{
vm.PermissionValues.RemoveAt(index);
continue;
}
if (wanted?.Any() ?? false)
{
var commandParts = vm.Command?.Split(':', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>();
var command = commandParts.Length > 1 ? commandParts[1] : null;
var isPerformingAnAction = command == "change-store-mode" || command == "add-store";
// Don't want to accidentally change mode for the user if they are explicitly performing some action
if (isPerformingAnAction)
{
continue;
}
// Set the value to true and adjust the other fields based on the policy type
permissionValue.Value = true;
if (vm.SelectiveStores && Policies.IsStorePolicy(permissionValue.Permission) &&
wanted.Any(permission => !string.IsNullOrEmpty(permission.Scope)))
{
permissionValue.StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.Specific;
permissionValue.SpecificStores = wanted.Select(permission => permission.Scope).ToList();
}
else
{
permissionValue.StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.AllStores;
permissionValue.SpecificStores = new List<string>();
}
}
}
}
[HttpPost("~/api-keys/authorize")]
public async Task<IActionResult> AuthorizeAPIKey([FromForm] AuthorizeApiKeysViewModel viewModel)
{
await SetViewModelValues(viewModel);
viewModel = await SetViewModelValues(viewModel);
AdjustVMForAuthorization(viewModel);
var ar = HandleCommands(viewModel);
if (ar != null)
{
@ -332,6 +203,13 @@ namespace BTCPayServer.Controllers
return RedirectToAction("APIKeys", new { key = key.Id });
default:
var requestPermissions = Permission.ToPermissions(viewModel.Permissions?.Split(';').ToArray()).ToList();
var existingApiKey = await CheckForMatchingApiKey(viewModel.ApplicationIdentifier, viewModel.RedirectUrl, requestPermissions, viewModel.Strict);
if (existingApiKey != null)
{
viewModel.ApiKey = existingApiKey.Id;
return View("ConfirmAPIKey", viewModel);
}
return View(viewModel);
}
}
@ -363,6 +241,131 @@ namespace BTCPayServer.Controllers
return RedirectToAction("APIKeys");
}
private async Task<APIKeyData> CheckForMatchingApiKey(string applicationIdentifier, Uri redirect,
IEnumerable<Permission> requestPermissions, bool strict)
{
if (string.IsNullOrEmpty(applicationIdentifier) || redirect == null)
{
return 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) }
});
foreach (var key in keys)
{
var blob = key.GetBlob();
if (blob.ApplicationIdentifier != applicationIdentifier || blob.ApplicationAuthority != redirect.AbsoluteUri)
{
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)
.GroupBy(permission => permission.Policy);
var fail = false;
foreach (var permission in requestPermissions.GroupBy(permission => permission.Policy))
{
var presentPermission =
alreadyPresentPermissions.SingleOrDefault(grouping => permission.Key == grouping.Key);
if (strict && presentPermission == null)
{
fail = true;
break;
}
if (Policies.IsStorePolicy(permission.Key))
{
if (presentPermission.Any(permission1 => !string.IsNullOrEmpty(permission1.Scope)))
{
fail = true;
break;
}
}
}
if (fail)
{
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 key;
}
return null;
}
private void AdjustVMForAuthorization(AuthorizeApiKeysViewModel vm)
{
var storeIds = vm.SpecificStores.ToArray();
var permissions = vm.Permissions?.Split(';') ?? Array.Empty<string>();
var permissionsWithStoreIDs = new List<string>();
vm.NeedsStorePermission = permissions.Any(Policies.IsStorePolicy) || !vm.Strict;
// Go over each permission and associated store IDs and join them
// so that permission for a specific store is parsed correctly
foreach (var permission in permissions)
{
if (!Policies.IsStorePolicy(permission) || storeIds.Length == 0)
{
permissionsWithStoreIDs.Add(permission);
}
else
{
foreach (var t in storeIds)
{
permissionsWithStoreIDs.Add($"{permission}:{t}");
}
}
}
var parsedPermissions = Permission.ToPermissions(permissionsWithStoreIDs.ToArray()).GroupBy(permission => permission.Policy);
for (var index = vm.PermissionValues.Count - 1; index >= 0; index--)
{
var permissionValue = vm.PermissionValues[index];
var wanted = parsedPermissions.SingleOrDefault(permission =>
permission.Key.Equals(permissionValue.Permission,
StringComparison.InvariantCultureIgnoreCase));
if (vm.Strict && !(wanted?.Any() ?? false))
{
vm.PermissionValues.RemoveAt(index);
continue;
}
if (wanted?.Any() ?? false)
{
var commandParts = vm.Command?.Split(':', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>();
var command = commandParts.Length > 1 ? commandParts[1] : null;
var isPerformingAnAction = command == "change-store-mode" || command == "add-store";
// Don't want to accidentally change mode for the user if they are explicitly performing some action
if (isPerformingAnAction)
{
continue;
}
// Set the value to true and adjust the other fields based on the policy type
permissionValue.Value = true;
if (vm.SelectiveStores && Policies.IsStorePolicy(permissionValue.Permission) &&
wanted.Any(permission => !string.IsNullOrEmpty(permission.Scope)))
{
permissionValue.StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.Specific;
permissionValue.SpecificStores = wanted.Select(permission => permission.Scope).ToList();
}
else
{
permissionValue.StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.AllStores;
permissionValue.SpecificStores = new List<string>();
}
}
}
}
private IActionResult HandleCommands(AddApiKeyViewModel viewModel)
{
if (string.IsNullOrEmpty(viewModel.Command))

View file

@ -2,9 +2,10 @@
@model BTCPayServer.Controllers.UIManageController.AuthorizeApiKeysViewModel
@{
Layout = "_LayoutWizard";
ViewData["Title"] = $"Authorize {Model.ApplicationName ?? "Application"}";
var displayName = Model.ApplicationName ?? Model.ApplicationIdentifier;
var permissions = Permission.ToPermissions(Model.Permissions.Split(';')).GroupBy(permission => permission.Policy);
ViewData["Title"] = $"Authorize {displayName ?? "Application"}";
Layout = "_LayoutWizard";
}
@section Navbar {
@ -40,7 +41,7 @@
<input type="hidden" asp-for="ApplicationIdentifier" value="@Model.ApplicationIdentifier"/>
<header class="text-center">
<h1>@ViewData["Title"]</h1>
<p class="lead text-secondary mt-3">@(Model.ApplicationName ?? "An application") is requesting access to your BTCPay Server account.</p>
<p class="lead text-secondary mt-3">@(displayName ?? "An application") is requesting access to your BTCPay Server account.</p>
</header>
<div asp-validation-summary="All" class="text-danger"></div>

View file

@ -2,44 +2,36 @@
@{
var displayName = Model.ApplicationName ?? Model.ApplicationIdentifier;
ViewData["Title"] = $"Are you sure about exposing your API Key to {displayName}?";
Layout = null;
ViewData["Title"] = $"Authorize {displayName ?? "Application"}";
Layout = "_LayoutWizard";
}
<!DOCTYPE html>
<html lang="en">
<head>
<partial name="LayoutHead" />
</head>
<body class="bg-light">
<div class="modal-dialog modal-dialog-centered min-vh-100">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title w-100 text-center">@ViewData["Title"]</h3>
</div>
<div class="modal-body text-center">
You've previously generated the API Key <code>@Model.ApiKey</code> specifically for
<strong>@displayName</strong> with the URL <code>@Model.RedirectUrl</code>.
</div>
<form method="post" class="modal-footer justify-content-center" asp-controller="UIManage" asp-action="AuthorizeAPIKey">
<input type="hidden" asp-for="ApplicationName" value="@Model.ApplicationName"/>
<input type="hidden" asp-for="ApplicationIdentifier" value="@Model.ApplicationIdentifier"/>
<input type="hidden" asp-for="ApiKey" value="@Model.ApiKey"/>
<input type="hidden" asp-for="Strict" value="@Model.Strict"/>
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
<input type="hidden" asp-for="Permissions" value="@Model.Permissions"/>
<input type="hidden" asp-for="SelectiveStores" value="@Model.SelectiveStores"/>
<button type="submit" class="btn btn-primary w-25 mx-2" id="continue" name="command" value="Confirm">Confirm</button>
<button type="submit" class="btn btn-secondary w-25 mx-2 only-for-js" id="back">Go back</button>
</form>
</div>
</div>
<partial name="LayoutFoot" />
@section PageFootContent {
<script>
delegate('click', '#back', () => { history.back() });
</script>
</body>
</html>
}
<partial name="_StatusMessage" />
<form method="post" asp-controller="UIManage" asp-action="AuthorizeAPIKey">
<input type="hidden" asp-for="ApplicationName" value="@Model.ApplicationName"/>
<input type="hidden" asp-for="ApplicationIdentifier" value="@Model.ApplicationIdentifier"/>
<input type="hidden" asp-for="ApiKey" value="@Model.ApiKey"/>
<input type="hidden" asp-for="Strict" value="@Model.Strict"/>
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
<input type="hidden" asp-for="Permissions" value="@Model.Permissions"/>
<input type="hidden" asp-for="SelectiveStores" value="@Model.SelectiveStores"/>
<header class="text-center">
<h1>@ViewData["Title"]</h1>
<p class="lead text-secondary mt-3">Are you sure about exposing your API Key to @displayName?</p>
</header>
<p>
You've previously generated the API Key <code>@Model.ApiKey</code> specifically for
<strong>@displayName</strong> with the URL <code>@Model.RedirectUrl</code>.
</p>
<div class="d-flex gap-3">
<button class="btn btn-primary" name="command" id="continue" type="submit" value="Confirm">Authorize app</button>
<button class="btn btn-secondary" name="command" id="back" type="button">Cancel</button>
</div>
</form>