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 Xunit.Abstractions;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using BTCPayServer.Authentication;
using BTCPayServer.Data; using BTCPayServer.Data;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -108,7 +109,8 @@ namespace BTCPayServer.Tests
ClientId = id, ClientId = id,
DisplayName = id, DisplayName = id,
Permissions = {OpenIddictConstants.Permissions.GrantTypes.Implicit}, Permissions = {OpenIddictConstants.Permissions.GrantTypes.Implicit},
RedirectUris = {redirecturi} RedirectUris = {redirecturi},
}); });
var implicitAuthorizeUrl = new Uri(tester.PayTester.ServerUri, 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()}"); $"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); await TestApiAgainstAccessToken(results["access_token"], tester, user);
LogoutFlow(tester, id, s); 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.Driver.Navigate().GoToUrl(implicitAuthorizeUrl);
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password); s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
@@ -135,6 +137,42 @@ namespace BTCPayServer.Tests
results = url.Split("#").Last().Split("&") results = url.Split("#").Last().Split("&")
.ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]); .ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
await TestApiAgainstAccessToken(results["access_token"], tester, user); 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", $"api/test/me/stores/{testAccount.StoreId}/can-edit",
tester.PayTester.HttpClient)); tester.PayTester.HttpClient));
Assert.Equal(testAccount.RegisterDetails.IsAdmin, await TestApiAgainstAccessToken<bool>(accessToken, Assert.Equal(testAccount.RegisterDetails.IsAdmin, await TestApiAgainstAccessToken<bool>(accessToken,
$"api/test/me/is-admin", $"api/test/me/is-admin",
tester.PayTester.HttpClient)); 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), ApplicationName = await _applicationManager.GetDisplayNameAsync(application),
RequestId = request.RequestId, RequestId = request.RequestId,
Scope = request.Scope Scope = request.GetScopes()
}); });
} }

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Authentication;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Security; using BTCPayServer.Security;
@@ -41,9 +42,10 @@ namespace BTCPayServer.Controllers.RestApi
[HttpGet("me/is-admin")] [HttpGet("me/is-admin")]
public bool AmIAnAdmin() public bool AmIAnAdmin()
{ {
return User.IsInRole(Roles.ServerAdmin); return User.IsInRole(Roles.ServerAdmin);
} }
[HttpGet("me/stores")] [HttpGet("me/stores")]
public async Task<StoreData[]> GetCurrentUserStores() public async Task<StoreData[]> GetCurrentUserStores()
{ {
@@ -52,10 +54,63 @@ namespace BTCPayServer.Controllers.RestApi
[HttpGet("me/stores/{storeId}/can-edit")] [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) public bool CanEdit(string storeId)
{ {
return true; 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>(); services.AddSingleton<EmailSenderFactory>();
// bundling // bundling
services.AddAuthorization(o => Policies.AddBTCPayPolicies(o)); services.AddAuthorization(o => o.AddBTCPayPolicies().AddBTCPayRESTApiPolicies());
services.AddBtcPayServerAuthenticationSchemes(configuration); services.AddBtcPayServerAuthenticationSchemes(configuration);
services.AddSingleton<IBundleProvider, ResourceBundleProvider>(); services.AddSingleton<IBundleProvider, ResourceBundleProvider>();

View File

@@ -19,10 +19,13 @@ using Microsoft.AspNetCore.Server.Kestrel.Core;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using OpenIddict.EntityFrameworkCore.Models; using OpenIddict.EntityFrameworkCore.Models;
using System.Net; using System.Net;
using BTCPayServer.Authentication;
using BTCPayServer.Authentication.OpenId; using BTCPayServer.Authentication.OpenId;
using BTCPayServer.PaymentRequest; using BTCPayServer.PaymentRequest;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Storage; using BTCPayServer.Storage;
using Microsoft.Extensions.Options;
using OpenIddict.Core;
namespace BTCPayServer.Hosting namespace BTCPayServer.Hosting
{ {
@@ -160,7 +163,7 @@ namespace BTCPayServer.Hosting
options.EnableLogoutEndpoint("/connect/logout"); options.EnableLogoutEndpoint("/connect/logout");
//we do not care about these granular controls for now //we do not care about these granular controls for now
options.DisableScopeValidation(); options.IgnoreScopePermissions();
options.IgnoreEndpointPermissions(); options.IgnoreEndpointPermissions();
// Allow client applications various flows // Allow client applications various flows
options.AllowImplicitFlow(); options.AllowImplicitFlow();
@@ -176,7 +179,14 @@ namespace BTCPayServer.Hosting
OpenIdConnectConstants.Scopes.OfflineAccess, OpenIdConnectConstants.Scopes.OfflineAccess,
OpenIdConnectConstants.Scopes.Email, OpenIdConnectConstants.Scopes.Email,
OpenIdConnectConstants.Scopes.Profile, 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<PasswordGrantTypeEventHandler>();
options.AddEventHandler<AuthorizationCodeGrantTypeEventHandler>(); options.AddEventHandler<AuthorizationCodeGrantTypeEventHandler>();
options.AddEventHandler<RefreshTokenGrantTypeEventHandler>(); 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; using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace BTCPayServer.Models.Authorization namespace BTCPayServer.Models.Authorization
@@ -9,6 +10,6 @@ namespace BTCPayServer.Models.Authorization
[BindNever] public string RequestId { get; set; } [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"> <form method="post">
<input type="hidden" name="request_id" value="@Model.RequestId"/> <input type="hidden" name="request_id" value="@Model.RequestId"/>
<section> <section>
<div class="container"> <div class="card container">
<div class="row"> <div class="row">
<div class="col-lg-12 text-center"> <div class="col-lg-12 text-center">
<h2 class="section-heading">Authorization Request</h2> <h2 class="section-heading">Authorization Request</h2>
@@ -11,9 +30,26 @@
</div> </div>
</div> </div>
<div class="row"> <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="col-lg-12 text-center">
<div class="btn-group"> <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"> <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> <span class="sr-only">Toggle Dropdown</span>
</button> </button>

View File

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