Authorize granular permissions (#1057)

* granular scope permissions for api

* final fixes and styling

* prettify code

* fix missing policy
This commit is contained in:
Andrew Camilleri
2019-09-29 09:23:31 +02:00
committed by Nicolas Dorier
parent c7e3241a85
commit 3366c86b16
9 changed files with 260 additions and 15 deletions

View File

@@ -11,6 +11,7 @@ using Xunit;
using Xunit.Abstractions;
using System.Net.Http;
using System.Net.Http.Headers;
using BTCPayServer.Authentication;
using BTCPayServer.Data;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -108,7 +109,8 @@ namespace BTCPayServer.Tests
ClientId = id,
DisplayName = id,
Permissions = {OpenIddictConstants.Permissions.GrantTypes.Implicit},
RedirectUris = {redirecturi}
RedirectUris = {redirecturi},
});
var implicitAuthorizeUrl = new Uri(tester.PayTester.ServerUri,
$"connect/authorize?response_type=token&client_id={id}&redirect_uri={redirecturi.AbsoluteUri}&scope=openid&nonce={Guid.NewGuid().ToString()}");
@@ -127,7 +129,7 @@ namespace BTCPayServer.Tests
await TestApiAgainstAccessToken(results["access_token"], tester, user);
LogoutFlow(tester, id, s);
//we dont ask for consent after acquiring it the first time for the same scopes.
s.Driver.Navigate().GoToUrl(implicitAuthorizeUrl);
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
@@ -135,6 +137,42 @@ namespace BTCPayServer.Tests
results = url.Split("#").Last().Split("&")
.ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
await TestApiAgainstAccessToken(results["access_token"], tester, user);
//let's test out scopes!
implicitAuthorizeUrl = new Uri(tester.PayTester.ServerUri,
$"connect/authorize?response_type=token&client_id={id}&redirect_uri={redirecturi.AbsoluteUri}&scope=openid {RestAPIPolicies.BTCPayScopes.AppManagement} {RestAPIPolicies.BTCPayScopes.ViewStores} &nonce={Guid.NewGuid().ToString()}");
s.Driver.Navigate().GoToUrl(implicitAuthorizeUrl);
//authorize form should show now that we have asked for more scopes
s.Driver.FindElement(By.Id("consent-yes")).Click();
url = s.Driver.Url;
results = url.Split("#").Last().Split("&")
.ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
Assert.True(await TestApiAgainstAccessToken<bool>(results["access_token"],
$"api/test/ScopeCanViewApps",
tester.PayTester.HttpClient));
Assert.True(await TestApiAgainstAccessToken<bool>(results["access_token"],
$"api/test/ScopeCanManageApps",
tester.PayTester.HttpClient));
Assert.True(await TestApiAgainstAccessToken<bool>(results["access_token"],
$"api/test/ScopeCanViewStores",
tester.PayTester.HttpClient));
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(results["access_token"],
$"api/test/ScopeCanManageStores",
tester.PayTester.HttpClient);
});
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(results["access_token"],
$"api/test/ScopeCanViewProfile",
tester.PayTester.HttpClient);
});
}
}
@@ -353,7 +391,6 @@ namespace BTCPayServer.Tests
$"api/test/me/stores/{testAccount.StoreId}/can-edit",
tester.PayTester.HttpClient));
Assert.Equal(testAccount.RegisterDetails.IsAdmin, await TestApiAgainstAccessToken<bool>(accessToken,
$"api/test/me/is-admin",
tester.PayTester.HttpClient));

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using OpenIddict.Abstractions;
namespace BTCPayServer.Authentication
{
public static class RestAPIPolicies
{
public static class BTCPayScopes
{
public const string ViewStores = "view_stores";
//Create and manage stores
public const string StoreManagement = "store_management";
//create and manage invoices
public const string ViewInvoices = "view_invoices";
//create and manage invoices
public const string CreateInvoices = "create_invoice";
public const string InvoiceManagement = "manage_invoices";
//view apps
public const string ViewApps = "view_apps";
//create and manage apps
public const string AppManagement = "app_management";
public const string WalletManagement = "wallet_management";
}
public const string CanViewStores = nameof(CanViewStores);
public const string CanManageStores = nameof(CanManageStores);
public const string CanViewInvoices = nameof(CanViewInvoices);
public const string CanCreateInvoices = nameof(CanCreateInvoices);
public const string CanManageInvoices = nameof(CanManageInvoices);
public const string CanManageApps = nameof(CanManageApps);
public const string CanViewApps = nameof(CanViewApps);
public const string CanManageWallet = nameof(CanManageWallet);
public const string CanViewProfile = nameof(CanViewProfile);
public static AuthorizationOptions AddBTCPayRESTApiPolicies(this AuthorizationOptions options)
{
AddScopePolicy(options, CanViewStores,
context => context.HasScopes(BTCPayScopes.StoreManagement) ||
context.HasScopes(BTCPayScopes.ViewStores));
AddScopePolicy(options, CanManageStores,
context => context.HasScopes(BTCPayScopes.StoreManagement));
AddScopePolicy(options, CanViewInvoices,
context => context.HasScopes(BTCPayScopes.ViewInvoices) ||
context.HasScopes(BTCPayScopes.InvoiceManagement));
AddScopePolicy(options, CanCreateInvoices,
context => context.HasScopes(BTCPayScopes.CreateInvoices) ||
context.HasScopes(BTCPayScopes.InvoiceManagement));
AddScopePolicy(options, CanViewApps,
context => context.HasScopes(BTCPayScopes.AppManagement) || context.HasScopes(BTCPayScopes.ViewApps));
AddScopePolicy(options, CanManageInvoices,
context => context.HasScopes(BTCPayScopes.InvoiceManagement));
AddScopePolicy(options, CanManageApps,
context => context.HasScopes(BTCPayScopes.AppManagement));
AddScopePolicy(options, CanManageWallet,
context => context.HasScopes(BTCPayScopes.WalletManagement));
AddScopePolicy(options, CanViewProfile,
context => context.HasScopes(OpenIddictConstants.Scopes.Profile));
return options;
}
private static void AddScopePolicy(AuthorizationOptions options, string name,
Func<AuthorizationHandlerContext, bool> scopeGroups)
{
options.AddPolicy(name,
builder => builder.AddRequirements(new LambdaRequirement(scopeGroups)));
}
public static bool HasScopes(this AuthorizationHandlerContext context, params string[] scopes)
{
return scopes.All(s => context.User.HasClaim(OpenIddictConstants.Claims.Scope, s));
}
}
public class LambdaRequirement :
AuthorizationHandler<LambdaRequirement>, IAuthorizationRequirement
{
private readonly Func<AuthorizationHandlerContext, bool> _Func;
public LambdaRequirement(Func<AuthorizationHandlerContext, bool> func)
{
_Func = func;
}
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, LambdaRequirement requirement)
{
if (_Func.Invoke(context))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
}

View File

@@ -78,7 +78,7 @@ namespace BTCPayServer.Controllers
{
ApplicationName = await _applicationManager.GetDisplayNameAsync(application),
RequestId = request.RequestId,
Scope = request.Scope
Scope = request.GetScopes()
});
}

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using BTCPayServer.Authentication;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Security;
@@ -41,9 +42,10 @@ namespace BTCPayServer.Controllers.RestApi
[HttpGet("me/is-admin")]
public bool AmIAnAdmin()
{
return User.IsInRole(Roles.ServerAdmin);
{
return User.IsInRole(Roles.ServerAdmin);
}
[HttpGet("me/stores")]
public async Task<StoreData[]> GetCurrentUserStores()
{
@@ -52,10 +54,63 @@ namespace BTCPayServer.Controllers.RestApi
[HttpGet("me/stores/{storeId}/can-edit")]
[Authorize(Policy = Policies.CanModifyStoreSettings.Key, AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
public bool CanEdit(string storeId)
{
return true;
}
#region scopes tests
[Authorize(Policy = RestAPIPolicies.CanViewStores,
AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[HttpGet(nameof(ScopeCanViewStores))]
public bool ScopeCanViewStores() { return true; }
[Authorize(Policy = RestAPIPolicies.CanManageStores,
AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[HttpGet(nameof(ScopeCanManageStores))]
public bool ScopeCanManageStores() { return true; }
[Authorize(Policy = RestAPIPolicies.CanViewInvoices,
AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[HttpGet(nameof(ScopeCanViewInvoices))]
public bool ScopeCanViewInvoices() { return true; }
[Authorize(Policy = RestAPIPolicies.CanCreateInvoices,
AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[HttpGet(nameof(ScopeCanCreateInvoices))]
public bool ScopeCanCreateInvoices() { return true; }
[Authorize(Policy = RestAPIPolicies.CanManageInvoices,
AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[HttpGet(nameof(ScopeCanManageInvoices))]
public bool ScopeCanManageInvoices() { return true; }
[Authorize(Policy = RestAPIPolicies.CanManageApps,
AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[HttpGet(nameof(ScopeCanManageApps))]
public bool ScopeCanManageApps() { return true; }
[Authorize(Policy = RestAPIPolicies.CanViewApps,
AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[HttpGet(nameof(ScopeCanViewApps))]
public bool ScopeCanViewApps() { return true; }
[Authorize(Policy = RestAPIPolicies.CanManageWallet,
AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[HttpGet(nameof(ScopeCanManageWallet))]
public bool ScopeCanManageWallet() { return true; }
[Authorize(Policy = RestAPIPolicies.CanViewProfile,
AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
[HttpGet(nameof(ScopeCanViewProfile))]
public bool ScopeCanViewProfile() { return true; }
#endregion
}
}

View File

@@ -237,7 +237,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<EmailSenderFactory>();
// bundling
services.AddAuthorization(o => Policies.AddBTCPayPolicies(o));
services.AddAuthorization(o => o.AddBTCPayPolicies().AddBTCPayRESTApiPolicies());
services.AddBtcPayServerAuthenticationSchemes(configuration);
services.AddSingleton<IBundleProvider, ResourceBundleProvider>();

View File

@@ -19,10 +19,13 @@ using Microsoft.AspNetCore.Server.Kestrel.Core;
using OpenIddict.Abstractions;
using OpenIddict.EntityFrameworkCore.Models;
using System.Net;
using BTCPayServer.Authentication;
using BTCPayServer.Authentication.OpenId;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Services.Apps;
using BTCPayServer.Storage;
using Microsoft.Extensions.Options;
using OpenIddict.Core;
namespace BTCPayServer.Hosting
{
@@ -160,7 +163,7 @@ namespace BTCPayServer.Hosting
options.EnableLogoutEndpoint("/connect/logout");
//we do not care about these granular controls for now
options.DisableScopeValidation();
options.IgnoreScopePermissions();
options.IgnoreEndpointPermissions();
// Allow client applications various flows
options.AllowImplicitFlow();
@@ -176,7 +179,14 @@ namespace BTCPayServer.Hosting
OpenIdConnectConstants.Scopes.OfflineAccess,
OpenIdConnectConstants.Scopes.Email,
OpenIdConnectConstants.Scopes.Profile,
OpenIddictConstants.Scopes.Roles);
OpenIddictConstants.Scopes.Roles,
RestAPIPolicies.BTCPayScopes.ViewStores,
RestAPIPolicies.BTCPayScopes.CreateInvoices,
RestAPIPolicies.BTCPayScopes.StoreManagement,
RestAPIPolicies.BTCPayScopes.ViewApps,
RestAPIPolicies.BTCPayScopes.AppManagement
);
options.AddEventHandler<PasswordGrantTypeEventHandler>();
options.AddEventHandler<AuthorizationCodeGrantTypeEventHandler>();
options.AddEventHandler<RefreshTokenGrantTypeEventHandler>();

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace BTCPayServer.Models.Authorization
@@ -9,6 +10,6 @@ namespace BTCPayServer.Models.Authorization
[BindNever] public string RequestId { get; set; }
[Display(Name = "Scope")] public string Scope { get; set; }
[Display(Name = "Scope")] public IEnumerable<string> Scope { get; set; }
}
}

View File

@@ -1,8 +1,27 @@
@model BTCPayServer.Models.Authorization.AuthorizeViewModel
@using BTCPayServer.Authentication
@using OpenIddict.Abstractions
@model BTCPayServer.Models.Authorization.AuthorizeViewModel
@{
var scopeMappings = new Dictionary<string, (string Title, string Description)>()
{
{RestAPIPolicies.BTCPayScopes.AppManagement, ("Manage your apps", "The app will be able to create, modify and delete all your apps.")},
{RestAPIPolicies.BTCPayScopes.ViewApps, ("View your apps", "The app will be able to list and view all your apps.")},
{RestAPIPolicies.BTCPayScopes.CreateInvoices, ("Create invoices", "The app will be able to create new invoices.")},
{RestAPIPolicies.BTCPayScopes.InvoiceManagement, ("Manage invoices", "The app will be able to create new invoices and mark existing invoices as complete or invalid.")},
{RestAPIPolicies.BTCPayScopes.StoreManagement, ("Manage your stores", "The app will be able to create, modify and delete all your stores.")},
{RestAPIPolicies.BTCPayScopes.ViewStores, ("View your stores", "The app will be able to list and view all your stores.")},
{RestAPIPolicies.BTCPayScopes.WalletManagement, ("Manage your wallet", "The app will be able to manage your wallet associate to stores. This includes configuring it, transaction creation and signing.")},
{RestAPIPolicies.BTCPayScopes.ViewInvoices, ("View your invoices", "The app will be able to list and view all your apps.")},
{OpenIddictConstants.Scopes.Email, ("View your email", "The app will have access to your email.")},
{OpenIddictConstants.Scopes.Profile, ("View your account", "The app will have access to your account details.")},
{OpenIddictConstants.Scopes.Roles, ("View your roles", "The app will know if you are a server admin.")},
};
}
<form method="post">
<input type="hidden" name="request_id" value="@Model.RequestId"/>
<section>
<div class="container">
<div class="card container">
<div class="row">
<div class="col-lg-12 text-center">
<h2 class="section-heading">Authorization Request</h2>
@@ -11,9 +30,26 @@
</div>
</div>
<div class="row">
<div class="col-lg-12">
<div class="list-group list-group-flush">
@foreach (var scope in Model.Scope)
{
@if (scopeMappings.TryGetValue(scope, out var text))
{
<li class="list-group-item">
<h5 class="mb-1">@text.Title</h5>
<p class="mb-1">@text.Description.</p>
</li>
}
}
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-lg-12 text-center">
<div class="btn-group">
<button class="btn btn-lg btn-success" name="consent" id="consent-yes" type="submit" value="Yes">Authorize app</button>
<button class="btn btn-lg btn-primary" name="consent" id="consent-yes" type="submit" value="Yes">Authorize app</button>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="sr-only">Toggle Dropdown</span>
</button>

View File

@@ -11,3 +11,4 @@
<partial name="@extension.Partial"/>
}
</div>