Api keys with openiddict (#1262)

* Remove OpenIddict

* Add API Key system

* Revert removing OpenIddict

* fix rebase

* fix tests

* pr changes

* fix tests

* fix apikey test

* pr change

* fix db

* add migration attrs

* fix migration error

* PR Changes

* Fix sqlite migration

* change api key to use Authorization Header

* add supportAddForeignKey

* use tempdata status message

* fix add api key css

* remove redirect url + app identifier feature :(
This commit is contained in:
Andrew Camilleri
2020-02-24 14:36:15 +01:00
committed by GitHub
parent a3e7729c52
commit fa51180dfa
29 changed files with 1502 additions and 44 deletions

View File

@@ -0,0 +1,295 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Security;
using BTCPayServer.Security.APIKeys;
using ExchangeSharp;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace BTCPayServer.Controllers
{
public partial class ManageController
{
[HttpGet]
public async Task<IActionResult> APIKeys()
{
return View(new ApiKeysViewModel()
{
ApiKeyDatas = await _apiKeyRepository.GetKeys(new APIKeyRepository.APIKeyQuery()
{
UserId = new[] {_userManager.GetUserId(User)}
})
});
}
[HttpGet]
public async Task<IActionResult> RemoveAPIKey(string id)
{
await _apiKeyRepository.Remove(id, _userManager.GetUserId(User));
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "API Key removed"
});
return RedirectToAction("APIKeys");
}
[HttpGet]
public async Task<IActionResult> AddApiKey()
{
if (!_btcPayServerEnvironment.IsSecure)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "Cannot generate api keys while not on https or tor"
});
return RedirectToAction("APIKeys");
}
return View("AddApiKey", await SetViewModelValues(new AddApiKeyViewModel()));
}
[HttpGet("~/api-keys/authorize")]
public async Task<IActionResult> AuthorizeAPIKey( string[] permissions, string applicationName = null,
bool strict = true, bool selectiveStores = false)
{
if (!_btcPayServerEnvironment.IsSecure)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "Cannot generate api keys while not on https or tor"
});
return RedirectToAction("APIKeys");
}
permissions ??= Array.Empty<string>();
var vm = await SetViewModelValues(new AuthorizeApiKeysViewModel()
{
ServerManagementPermission = permissions.Contains(APIKeyConstants.Permissions.ServerManagement),
StoreManagementPermission = permissions.Contains(APIKeyConstants.Permissions.StoreManagement),
PermissionsFormatted = permissions,
ApplicationName = applicationName,
SelectiveStores = selectiveStores,
Strict = strict,
});
vm.ServerManagementPermission = vm.ServerManagementPermission && vm.IsServerAdmin;
return View(vm);
}
[HttpPost("~/api-keys/authorize")]
public async Task<IActionResult> AuthorizeAPIKey([FromForm] AuthorizeApiKeysViewModel viewModel)
{
await SetViewModelValues(viewModel);
var ar = HandleCommands(viewModel);
if (ar != null)
{
return ar;
}
if (viewModel.PermissionsFormatted.Contains(APIKeyConstants.Permissions.ServerManagement))
{
if (!viewModel.IsServerAdmin && viewModel.ServerManagementPermission)
{
viewModel.ServerManagementPermission = false;
}
if (!viewModel.ServerManagementPermission && viewModel.Strict)
{
ModelState.AddModelError(nameof(viewModel.ServerManagementPermission),
"This permission is required for this application.");
}
}
if (viewModel.PermissionsFormatted.Contains(APIKeyConstants.Permissions.StoreManagement))
{
if (!viewModel.SelectiveStores &&
viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific)
{
viewModel.StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.AllStores;
ModelState.AddModelError(nameof(viewModel.StoreManagementPermission),
"This application does not allow selective store permissions.");
}
if (!viewModel.StoreManagementPermission && !viewModel.SpecificStores.Any() && viewModel.Strict)
{
ModelState.AddModelError(nameof(viewModel.StoreManagementPermission),
"This permission is required for this application.");
}
}
if (!ModelState.IsValid)
{
return View(viewModel);
}
switch (viewModel.Command.ToLowerInvariant())
{
case "no":
return RedirectToAction("APIKeys");
case "yes":
var key = await CreateKey(viewModel);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Html = $"API key generated! <code>{key.Id}</code>"
});
return RedirectToAction("APIKeys", new { key = key.Id});
default: return View(viewModel);
}
}
[HttpPost]
public async Task<IActionResult> AddApiKey(AddApiKeyViewModel viewModel)
{
await SetViewModelValues(viewModel);
var ar = HandleCommands(viewModel);
if (ar != null)
{
return ar;
}
if (!ModelState.IsValid)
{
return View(viewModel);
}
var key = await CreateKey(viewModel);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Html = $"API key generated! <code>{key.Id}</code>"
});
return RedirectToAction("APIKeys");
}
private IActionResult HandleCommands(AddApiKeyViewModel viewModel)
{
switch (viewModel.Command)
{
case "change-store-mode":
viewModel.StoreMode = viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific
? AddApiKeyViewModel.ApiKeyStoreMode.AllStores
: AddApiKeyViewModel.ApiKeyStoreMode.Specific;
if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific &&
!viewModel.SpecificStores.Any() && viewModel.Stores.Any())
{
viewModel.SpecificStores.Add(null);
}
return View(viewModel);
case "add-store":
viewModel.SpecificStores.Add(null);
return View(viewModel);
case string x when x.StartsWith("remove-store", StringComparison.InvariantCultureIgnoreCase):
{
ModelState.Clear();
var index = int.Parse(
viewModel.Command.Substring(
viewModel.Command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1),
CultureInfo.InvariantCulture);
viewModel.SpecificStores.RemoveAt(index);
return View(viewModel);
}
}
return null;
}
private async Task<APIKeyData> CreateKey(AddApiKeyViewModel viewModel)
{
var key = new APIKeyData()
{
Id = Guid.NewGuid().ToString(), Type = APIKeyType.Permanent, UserId = _userManager.GetUserId(User)
};
key.SetPermissions(GetPermissionsFromViewModel(viewModel));
await _apiKeyRepository.CreateKey(key);
return key;
}
private IEnumerable<string> GetPermissionsFromViewModel(AddApiKeyViewModel viewModel)
{
var permissions = new List<string>();
if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific)
{
permissions.AddRange(viewModel.SpecificStores.Select(APIKeyConstants.Permissions.GetStorePermission));
}
else if (viewModel.StoreManagementPermission)
{
permissions.Add(APIKeyConstants.Permissions.StoreManagement);
}
if (viewModel.IsServerAdmin && viewModel.ServerManagementPermission)
{
permissions.Add(APIKeyConstants.Permissions.ServerManagement);
}
return permissions;
}
private async Task<T> SetViewModelValues<T>(T viewModel) where T : AddApiKeyViewModel
{
viewModel.Stores = await _StoreRepository.GetStoresByUserId(_userManager.GetUserId(User));
viewModel.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings.Key)).Succeeded;
return viewModel;
}
public class AddApiKeyViewModel
{
public StoreData[] Stores { get; set; }
public ApiKeyStoreMode StoreMode { get; set; }
public List<string> SpecificStores { get; set; } = new List<string>();
public bool IsServerAdmin { get; set; }
public bool ServerManagementPermission { get; set; }
public bool StoreManagementPermission { get; set; }
public string Command { get; set; }
public enum ApiKeyStoreMode
{
AllStores,
Specific
}
}
public class AuthorizeApiKeysViewModel : AddApiKeyViewModel
{
public string ApplicationName { get; set; }
public bool Strict { get; set; }
public bool SelectiveStores { get; set; }
public string Permissions { get; set; }
public string[] PermissionsFormatted
{
get
{
return Permissions?.Split(";", StringSplitOptions.RemoveEmptyEntries);
}
set
{
Permissions = string.Join(';', value ?? Array.Empty<string>());
}
}
}
public class ApiKeysViewModel
{
public List<APIKeyData> ApiKeyDatas { get; set; }
}
}
}

View File

@@ -19,6 +19,8 @@ using System.Globalization;
using BTCPayServer.Security;
using BTCPayServer.U2F;
using BTCPayServer.Data;
using BTCPayServer.Security.APIKeys;
namespace BTCPayServer.Controllers
{
@@ -34,6 +36,8 @@ namespace BTCPayServer.Controllers
IWebHostEnvironment _Env;
public U2FService _u2FService;
private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
private readonly APIKeyRepository _apiKeyRepository;
private readonly IAuthorizationService _authorizationService;
StoreRepository _StoreRepository;
@@ -48,7 +52,10 @@ namespace BTCPayServer.Controllers
StoreRepository storeRepository,
IWebHostEnvironment env,
U2FService u2FService,
BTCPayServerEnvironment btcPayServerEnvironment)
BTCPayServerEnvironment btcPayServerEnvironment,
APIKeyRepository apiKeyRepository,
IAuthorizationService authorizationService
)
{
_userManager = userManager;
_signInManager = signInManager;
@@ -58,6 +65,8 @@ namespace BTCPayServer.Controllers
_Env = env;
_u2FService = u2FService;
_btcPayServerEnvironment = btcPayServerEnvironment;
_apiKeyRepository = apiKeyRepository;
_authorizationService = authorizationService;
_StoreRepository = storeRepository;
}

View File

@@ -0,0 +1,73 @@
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Security;
using BTCPayServer.Security.APIKeys;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers.RestApi
{
/// <summary>
/// this controller serves as a testing endpoint for our api key unit tests
/// </summary>
[Route("api/test/apikey")]
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public class TestApiKeyController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly StoreRepository _storeRepository;
public TestApiKeyController(UserManager<ApplicationUser> userManager, StoreRepository storeRepository)
{
_userManager = userManager;
_storeRepository = storeRepository;
}
[HttpGet("me/id")]
public string GetCurrentUserId()
{
return _userManager.GetUserId(User);
}
[HttpGet("me")]
public async Task<ApplicationUser> GetCurrentUser()
{
return await _userManager.GetUserAsync(User);
}
[HttpGet("me/is-admin")]
[Authorize(Policy = Policies.CanModifyServerSettings.Key, AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public bool AmIAnAdmin()
{
return true;
}
[HttpGet("me/stores")]
[Authorize(Policy = Policies.CanListStoreSettings.Key,
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public async Task<StoreData[]> GetCurrentUserStores()
{
return await User.GetStores(_userManager, _storeRepository);
}
[HttpGet("me/stores/actions")]
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public bool CanDoNonImplicitStoreActions()
{
return true;
}
[HttpGet("me/stores/{storeId}/can-edit")]
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public bool CanEdit(string storeId)
{
return true;
}
}
}

View File

@@ -13,15 +13,15 @@ namespace BTCPayServer.Controllers.RestApi
/// <summary>
/// this controller serves as a testing endpoint for our OpenId unit tests
/// </summary>
[Route("api/[controller]")]
[Route("api/test/openid")]
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.OpenId)]
public class TestController : ControllerBase
public class TestOpenIdController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly StoreRepository _storeRepository;
public TestController(UserManager<ApplicationUser> userManager, StoreRepository storeRepository)
public TestOpenIdController(UserManager<ApplicationUser> userManager, StoreRepository storeRepository)
{
_userManager = userManager;
_storeRepository = storeRepository;
@@ -54,7 +54,6 @@ namespace BTCPayServer.Controllers.RestApi
return await _storeRepository.GetStoresByUserId(_userManager.GetUserId(User));
}
[HttpGet("me/stores/{storeId}/can-edit")]
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
AuthenticationSchemes = AuthenticationSchemes.OpenId)]

View File

@@ -32,6 +32,7 @@ using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Changelly;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security;
using BTCPayServer.Security.APIKeys;
using BTCPayServer.Services.PaymentRequests;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBXplorer.DerivationStrategy;
@@ -241,11 +242,11 @@ namespace BTCPayServer.Hosting
services.AddTransient<PaymentRequestController>();
// Add application services.
services.AddSingleton<EmailSenderFactory>();
// bundling
services.AddBtcPayServerAuthenticationSchemes(configuration);
services.AddAPIKeyAuthentication();
services.AddBtcPayServerAuthenticationSchemes();
services.AddAuthorization(o => o.AddBTCPayPolicies());
// bundling
services.AddSingleton<IBundleProvider, ResourceBundleProvider>();
services.AddTransient<BundleOptions>(provider =>
{
@@ -292,12 +293,12 @@ namespace BTCPayServer.Hosting
return services;
}
private const long MAX_DEBUG_LOG_FILE_SIZE = 2000000; // If debug log is in use roll it every N MB.
private static void AddBtcPayServerAuthenticationSchemes(this IServiceCollection services,
IConfiguration configuration)
private static void AddBtcPayServerAuthenticationSchemes(this IServiceCollection services)
{
services.AddAuthentication()
.AddCookie()
.AddBitpayAuthentication();
.AddBitpayAuthentication()
.AddAPIKeyAuthentication();
}
public static IApplicationBuilder UsePayServer(this IApplicationBuilder app)

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Security.APIKeys
{
public class APIKeyAuthenticationHandler : AuthenticationHandler<APIKeyAuthenticationOptions>
{
private readonly APIKeyRepository _apiKeyRepository;
private readonly IOptionsMonitor<IdentityOptions> _identityOptions;
public APIKeyAuthenticationHandler(
APIKeyRepository apiKeyRepository,
IOptionsMonitor<IdentityOptions> identityOptions,
IOptionsMonitor<APIKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
_apiKeyRepository = apiKeyRepository;
_identityOptions = identityOptions;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Context.Request.HttpContext.GetAPIKey(out var apiKey) || string.IsNullOrEmpty(apiKey))
return AuthenticateResult.NoResult();
var key = await _apiKeyRepository.GetKey(apiKey);
if (key == null)
{
return AuthenticateResult.Fail("ApiKey authentication failed");
}
List<Claim> claims = new List<Claim>();
claims.Add(new Claim(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId));
claims.AddRange(key.GetPermissions()
.Select(permission => new Claim(APIKeyConstants.ClaimTypes.Permissions, permission)));
return AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal(new ClaimsIdentity(claims, APIKeyConstants.AuthenticationType)), APIKeyConstants.AuthenticationType));
}
}
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Authentication;
namespace BTCPayServer.Security.Bitpay
{
public class APIKeyAuthenticationOptions : AuthenticationSchemeOptions
{
}
}

View File

@@ -0,0 +1,84 @@
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
namespace BTCPayServer.Security.APIKeys
{
public class APIKeyAuthorizationHandler : AuthorizationHandler<PolicyRequirement>
{
private readonly HttpContext _HttpContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly StoreRepository _storeRepository;
public APIKeyAuthorizationHandler(IHttpContextAccessor httpContextAccessor,
UserManager<ApplicationUser> userManager,
StoreRepository storeRepository)
{
_HttpContext = httpContextAccessor.HttpContext;
_userManager = userManager;
_storeRepository = storeRepository;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
PolicyRequirement requirement)
{
if (context.User.Identity.AuthenticationType != APIKeyConstants.AuthenticationType)
return;
bool success = false;
switch (requirement.Policy)
{
case Policies.CanListStoreSettings.Key:
var selectiveStorePermissions =
APIKeyConstants.Permissions.ExtractStorePermissionsIds(context.GetPermissions());
success = context.HasPermissions(APIKeyConstants.Permissions.StoreManagement) ||
selectiveStorePermissions.Any();
break;
case Policies.CanModifyStoreSettings.Key:
string storeId = _HttpContext.GetImplicitStoreId();
if (!context.HasPermissions(APIKeyConstants.Permissions.StoreManagement) &&
!context.HasPermissions(APIKeyConstants.Permissions.GetStorePermission(storeId)))
break;
if (storeId == null)
{
success = true;
}
else
{
var userid = _userManager.GetUserId(context.User);
if (string.IsNullOrEmpty(userid))
break;
var store = await _storeRepository.FindStore((string)storeId, userid);
if (store == null)
break;
success = true;
_HttpContext.SetStoreData(store);
}
break;
case Policies.CanModifyServerSettings.Key:
if (!context.HasPermissions(APIKeyConstants.Permissions.ServerManagement))
break;
// For this authorization, we stil check in database because it is super sensitive.
var user = await _userManager.GetUserAsync(context.User);
if (user == null)
break;
if (!await _userManager.IsInRoleAsync(user, Roles.ServerAdmin))
break;
success = true;
break;
}
if (success)
{
context.Succeed(requirement);
}
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace BTCPayServer.Security.APIKeys
{
public static class APIKeyConstants
{
public const string AuthenticationType = "APIKey";
public static class ClaimTypes
{
public const string Permissions = nameof(APIKeys) + "." + nameof(Permissions);
}
public static class Permissions
{
public const string ServerManagement = nameof(ServerManagement);
public const string StoreManagement = nameof(StoreManagement);
public static readonly Dictionary<string, (string Title, string Description)> PermissionDescriptions = new Dictionary<string, (string Title, string Description)>()
{
{StoreManagement, ("Manage your stores", "The app will be able to create, modify and delete all your stores.")},
{$"{nameof(StoreManagement)}:", ("Manage selected stores", "The app will be able to modify and delete selected stores.")},
{ServerManagement, ("Manage your server", "The app will have total control on your server")},
};
public static string GetStorePermission(string storeId) => $"{nameof(StoreManagement)}:{storeId}";
public static IEnumerable<string> ExtractStorePermissionsIds(IEnumerable<string> permissions) => permissions
.Where(s => s.StartsWith($"{nameof(StoreManagement)}:", StringComparison.InvariantCulture))
.Select(s => s.Split(":")[1]);
}
}
}

View File

@@ -0,0 +1,75 @@
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
namespace BTCPayServer.Security.APIKeys
{
public static class APIKeyExtensions
{
public static bool GetAPIKey(this HttpContext httpContext, out StringValues apiKey)
{
if (httpContext.Request.Headers.TryGetValue("Authorization", out var value) &&
value.ToString().StartsWith("token ", StringComparison.InvariantCultureIgnoreCase))
{
apiKey = value.ToString().Substring("token ".Length);
return true;
}
return false;
}
public static Task<StoreData[]> GetStores(this ClaimsPrincipal claimsPrincipal,
UserManager<ApplicationUser> userManager, StoreRepository storeRepository)
{
var permissions =
claimsPrincipal.Claims.Where(claim => claim.Type == APIKeyConstants.ClaimTypes.Permissions)
.Select(claim => claim.Value).ToList();
if (permissions.Contains(APIKeyConstants.Permissions.StoreManagement))
{
return storeRepository.GetStoresByUserId(userManager.GetUserId(claimsPrincipal));
}
var storeIds = APIKeyConstants.Permissions.ExtractStorePermissionsIds(permissions);
return storeRepository.GetStoresByUserId(userManager.GetUserId(claimsPrincipal), storeIds);
}
public static AuthenticationBuilder AddAPIKeyAuthentication(this AuthenticationBuilder builder)
{
builder.AddScheme<APIKeyAuthenticationOptions, APIKeyAuthenticationHandler>(AuthenticationSchemes.ApiKey,
o => { });
return builder;
}
public static IServiceCollection AddAPIKeyAuthentication(this IServiceCollection serviceCollection)
{
serviceCollection.AddSingleton<APIKeyRepository>();
serviceCollection.AddScoped<IAuthorizationHandler, APIKeyAuthorizationHandler>();
return serviceCollection;
}
public static string[] GetPermissions(this AuthorizationHandlerContext context)
{
return context.User.Claims.Where(c =>
c.Type.Equals(APIKeyConstants.ClaimTypes.Permissions, StringComparison.InvariantCultureIgnoreCase))
.Select(claim => claim.Value).ToArray();
}
public static bool HasPermissions(this AuthorizationHandlerContext context, params string[] scopes)
{
return scopes.All(s => context.User.HasClaim(c =>
c.Type.Equals(APIKeyConstants.ClaimTypes.Permissions, StringComparison.InvariantCultureIgnoreCase) &&
c.Value.Split(' ').Contains(s)));
}
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Security.APIKeys
{
public class APIKeyRepository
{
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
public APIKeyRepository(ApplicationDbContextFactory applicationDbContextFactory)
{
_applicationDbContextFactory = applicationDbContextFactory;
}
public async Task<APIKeyData> GetKey(string apiKey)
{
using (var context = _applicationDbContextFactory.CreateContext())
{
return await EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(context.ApiKeys,
data => data.Id == apiKey && data.Type != APIKeyType.Legacy);
}
}
public async Task<List<APIKeyData>> GetKeys(APIKeyQuery query)
{
using (var context = _applicationDbContextFactory.CreateContext())
{
var queryable = context.ApiKeys.AsQueryable();
if (query?.UserId != null && query.UserId.Any())
{
queryable = queryable.Where(data => query.UserId.Contains(data.UserId));
}
return await queryable.ToListAsync();
}
}
public async Task CreateKey(APIKeyData key)
{
if (key.Type == APIKeyType.Legacy || !string.IsNullOrEmpty(key.StoreId) || string.IsNullOrEmpty(key.UserId))
{
throw new InvalidOperationException("cannot save a bitpay legacy api key with this repository");
}
using (var context = _applicationDbContextFactory.CreateContext())
{
await context.ApiKeys.AddAsync(key);
await context.SaveChangesAsync();
}
}
public async Task Remove(string id, string getUserId)
{
using (var context = _applicationDbContextFactory.CreateContext())
{
var key = await EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(context.ApiKeys,
data => data.Id == id && data.UserId == getUserId);
context.ApiKeys.Remove(key);
await context.SaveChangesAsync();
}
}
public class APIKeyQuery
{
public string[] UserId { get; set; }
}
}
}

View File

@@ -12,5 +12,6 @@ namespace BTCPayServer.Security
public const string Cookie = "Identity.Application";
public const string Bitpay = "Bitpay";
public const string OpenId = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
public const string ApiKey = "GreenfieldApiKey";
}
}

View File

@@ -66,10 +66,10 @@ namespace BTCPayServer.Security.Bitpay
using (var ctx = _Factory.CreateContext())
{
var existing = await ctx.ApiKeys.Where(o => o.StoreId == storeId).FirstOrDefaultAsync();
if (existing != null)
var existing = await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type == APIKeyType.Legacy).ToListAsync();
if (existing.Any())
{
ctx.ApiKeys.Remove(existing);
ctx.ApiKeys.RemoveRange(existing);
}
ctx.ApiKeys.Add(new APIKeyData() { Id = new string(generated), StoreId = storeId });
await ctx.SaveChangesAsync().ConfigureAwait(false);
@@ -95,7 +95,7 @@ namespace BTCPayServer.Security.Bitpay
{
using (var ctx = _Factory.CreateContext())
{
return await ctx.ApiKeys.Where(o => o.StoreId == storeId).Select(c => c.Id).ToArrayAsync();
return await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type== APIKeyType.Legacy).Select(c => c.Id).ToArrayAsync();
}
}

View File

@@ -11,6 +11,7 @@ namespace BTCPayServer.Security
public static AuthorizationOptions AddBTCPayPolicies(this AuthorizationOptions options)
{
options.AddPolicy(CanModifyStoreSettings.Key);
options.AddPolicy(CanListStoreSettings.Key);
options.AddPolicy(CanCreateInvoice.Key);
options.AddPolicy(CanGetRates.Key);
options.AddPolicy(CanModifyServerSettings.Key);
@@ -30,6 +31,10 @@ namespace BTCPayServer.Security
{
public const string Key = "btcpay.store.canmodifystoresettings";
}
public class CanListStoreSettings
{
public const string Key = "btcpay.store.canliststoresettings";
}
public class CanCreateInvoice
{
public const string Key = "btcpay.store.cancreateinvoice";

View File

@@ -78,12 +78,12 @@ namespace BTCPayServer.Services.Stores
}
}
public async Task<StoreData[]> GetStoresByUserId(string userId)
public async Task<StoreData[]> GetStoresByUserId(string userId, IEnumerable<string> storeIds = null)
{
using (var ctx = _ContextFactory.CreateContext())
{
return (await ctx.UserStore
.Where(u => u.ApplicationUserId == userId)
.Where(u => u.ApplicationUserId == userId && (storeIds == null || storeIds.Contains(u.StoreDataId)))
.Select(u => new { u.StoreData, u.Role })
.ToArrayAsync())
.Select(u =>

View File

@@ -0,0 +1,50 @@
@model BTCPayServer.Controllers.ManageController.ApiKeysViewModel
@{
ViewData.SetActivePageAndTitle(ManageNavPages.APIKeys, "Manage your API Keys");
}
<partial name="_StatusMessage"/>
<h4>API Keys</h4>
<table class="table table-lg">
<thead>
<tr>
<th >Key</th>
<th >Permissions</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var keyData in Model.ApiKeyDatas)
{
<tr>
<td>@keyData.Id</td>
<td>
@if (string.IsNullOrEmpty(keyData.Permissions))
{
<span>No permissions</span>
}
else
{
<span>@string.Join(", ", keyData.GetPermissions())</span>
}
</td>
<td class="text-right">
<a asp-action="RemoveAPIKey" asp-route-id="@keyData.Id">Remove</a>
</td>
</tr>
}
@if (!Model.ApiKeyDatas.Any())
{
<tr>
<td colspan="2" class="text-center h5 py-2">
No API keys
</td>
</tr>
}
<tr class="bg-gray">
<td colspan="3">
<a class="btn btn-primary" asp-action="AddApiKey" id="AddApiKey">Generate new key</a>
</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,120 @@
@using BTCPayServer.Controllers
@using BTCPayServer.Security.APIKeys
@model BTCPayServer.Controllers.ManageController.AddApiKeyViewModel
@{
ViewData.SetActivePageAndTitle(ManageNavPages.APIKeys, "Add API Key");
string GetDescription(string permission)
{
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
}
string GetTitle(string permission)
{
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
}
}
<h4>@ViewData["Title"]</h4>
<partial name="_StatusMessage"/>
<p >
Generate a new api key to use BTCPay through its API.
</p>
<div class="row">
<div class="col-md-12">
<form method="post" asp-action="AddApiKey" class="list-group">
<input type="hidden" asp-for="StoreMode" value="@Model.StoreMode"/>
<div asp-validation-summary="All" class="text-danger"></div>
@if (Model.IsServerAdmin)
{
<div class="list-group-item form-group">
<input asp-for="ServerManagementPermission" class="form-check-inline"/>
<label asp-for="ServerManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.ServerManagement)</label>
<span asp-validation-for="ServerManagementPermission" class="text-danger"></span>
<p>@GetDescription(APIKeyConstants.Permissions.ServerManagement).</p>
</div>
}
@if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores)
{
<div class="list-group-item form-group">
@Html.CheckBoxFor(model => model.StoreManagementPermission, new Dictionary<string, string>() {{"class", "form-check-inline"}})
<label asp-for="StoreManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.StoreManagement)</label>
<span asp-validation-for="StoreManagementPermission" class="text-danger"></span>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement).</p>
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to specific stores instead</button>
</div>
}
else if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.Specific)
{
<div class="list-group-item p-0 border-0 mb-2">
<li class="list-group-item ">
<h5 class="mb-1">@GetTitle(APIKeyConstants.Permissions.StoreManagement + ":")</h5>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement + ":").</p>
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to all stores instead</button>
</li>
@if (!Model.Stores.Any())
{
<li class="list-group-item alert-warning">
You currently have no stores configured.
</li>
}
@for (var index = 0; index < Model.SpecificStores.Count; index++)
{
<div class="list-group-item transaction-output-form p-0 pl-lg-2">
<div class="row">
<div class="col-sm-12 col-md-12 col-lg-10 py-2 ">
<div class="form-group my-0">
@if (Model.SpecificStores[index] == null)
{
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(Model.Stores.Where(data => !Model.SpecificStores.Contains(data.Id)), nameof(StoreData.Id), nameof(StoreData.StoreName)))"></select>
}
else
{
var store = Model.Stores.SingleOrDefault(data => data.Id == Model.SpecificStores[index]);
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(new[] {store}, nameof(StoreData.Id), nameof(StoreData.StoreName), store.Id))"></select>
}
<span asp-validation-for="SpecificStores[index]" class="text-danger"></span>
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-2 pull-right">
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-block d-lg-none d-xl-none btn btn-danger mb-2 ml-2">
Remove
</button>
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-none d-lg-block remove-btn text-decoration-none h-100 align-middle btn text-danger btn-link fa fa-times rounded-0 pull-right">
</button>
</div>
</div>
</div>
}
@if (Model.SpecificStores.Count < Model.Stores.Length)
{
<div class="list-group-item">
<button type="submit" name="command" value="add-store" class="ml-1 btn btn-secondary">Add another store </button>
</div>
}
</div>
}
<button type="submit" class="btn btn-primary" id="Generate">Generate API Key</button>
</form>
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
<style>
.remove-btn{
font-size: 1.5rem;
border-radius: 0;
}
.remove-btn:hover{
background-color: #CCCCCC;
}
</style>
}

View File

@@ -0,0 +1,137 @@
@using BTCPayServer.Controllers
@using BTCPayServer.Security.APIKeys
@model BTCPayServer.Controllers.ManageController.AuthorizeApiKeysViewModel
@{
Layout = "_Layout";
ViewData["Title"] = $"Authorize {(Model.ApplicationName ?? "Application")}";
string GetDescription(string permission)
{
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
}
string GetTitle(string permission)
{
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
}
}
<partial name="_StatusMessage"/>
<form method="post" asp-action="AuthorizeAPIKey">
<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"/>
<section>
<div class="card container">
<div class="row">
<div class="col-lg-12 section-heading">
<h2>Authorization Request</h2>
<hr class="primary">
<p class="mb-1">@(Model.ApplicationName ?? "An application") is requesting access to your account.</p>
</div>
</div>
<div class="row">
<div class="col-lg-12 list-group px-2">
<div asp-validation-summary="All" class="text-danger"></div>
@if (Model.PermissionsFormatted.Contains(APIKeyConstants.Permissions.ServerManagement) && (Model.IsServerAdmin || Model.Strict))
{
<div class="list-group-item form-group">
<input asp-for="ServerManagementPermission" class="form-check-inline" readonly="@(Model.Strict || !Model.IsServerAdmin)"/>
<label asp-for="ServerManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.ServerManagement)</label>
@if (!Model.IsServerAdmin)
{
<span class="text-danger">
The server management permission is being requested but your account is not an administrator
</span>
}
<span asp-validation-for="ServerManagementPermission" class="text-danger"></span>
<p>@GetDescription(APIKeyConstants.Permissions.ServerManagement).</p>
</div>
}
@if (Model.PermissionsFormatted.Contains(APIKeyConstants.Permissions.StoreManagement))
{
@if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores)
{
<div class="list-group-item form-group">
<input type="checkbox" asp-for="StoreManagementPermission" class="form-check-inline" readonly="@Model.Strict"/>
<label asp-for="StoreManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.StoreManagement)</label>
<span asp-validation-for="StoreManagementPermission" class="text-danger"></span>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement).</p>
@if (Model.SelectiveStores)
{
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to specific stores instead</button>
}
</div>
}
else if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.Specific)
{
<div class="list-group-item p-0 border-0 mb-2">
<li class="list-group-item">
<h5 class="mb-1">@GetTitle(APIKeyConstants.Permissions.StoreManagement + ":")</h5>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement + ":").</p>
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to all stores instead</button>
</li>
@if (!Model.Stores.Any())
{
<li class="list-group-item alert-warning">
You currently have no stores configured.
</li>
}
@for (var index = 0; index < Model.SpecificStores.Count; index++)
{
<div class="list-group-item transaction-output-form p-0 pl-lg-2">
<div class="row">
<div class="col-sm-12 col-md-12 col-lg-10 py-2 ">
<div class="form-group my-0">
@if (Model.SpecificStores[index] == null)
{
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(Model.Stores.Where(data => !Model.SpecificStores.Contains(data.Id)), nameof(StoreData.Id), nameof(StoreData.StoreName)))"></select>
}
else
{
var store = Model.Stores.SingleOrDefault(data => data.Id == Model.SpecificStores[index]);
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(new[] {store}, nameof(StoreData.Id), nameof(StoreData.StoreName), store.Id))"></select>
}
<span asp-validation-for="SpecificStores[index]" class="text-danger"></span>
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-2 pull-right">
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-block d-lg-none d-xl-none btn btn-danger mb-2 ml-2">
Remove
</button>
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-none d-lg-block remove-btn text-decoration-none h-100 align-middle btn text-danger btn-link fa fa-times rounded-0 pull-right">
</button>
</div>
</div>
</div>
}
@if (Model.SpecificStores.Count < Model.Stores.Length)
{
<div class="list-group-item">
<button type="submit" name="command" value="add-store" class="ml-1 btn btn-secondary">Add another store </button>
</div>
}
</div>
}
}
</div>
</div>
<div class="row my-2">
<div class="col-lg-12 text-center">
<div class="btn-group">
<button class="btn btn-primary" name="command" id="consent-yes" type="submit" value="Yes">Authorize app</button>
</div>
<button class="btn btn-secondary" id="consent-no" name="command" type="submit" value="No">Cancel</button>
</div>
</div>
</div>
</section>
</form>

View File

@@ -7,6 +7,6 @@ namespace BTCPayServer.Views.Manage
{
public enum ManageNavPages
{
Index, ChangePassword, TwoFactorAuthentication, U2F
Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys
}
}

View File

@@ -1,9 +1,10 @@
@inject SignInManager<ApplicationUser> SignInManager
<div class="nav flex-column nav-pills">
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.Index)" asp-action="Index">Profile</a>
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.ChangePassword)" asp-action="ChangePassword" id="ChangePassword">Password</a>
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-action="TwoFactorAuthentication">Two-factor authentication</a>
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.Index.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Index)" asp-action="Index">Profile</a>
<a id="@ManageNavPages.ChangePassword.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.ChangePassword)" asp-action="ChangePassword">Password</a>
<a id="@ManageNavPages.TwoFactorAuthentication.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-action="TwoFactorAuthentication">Two-factor authentication</a>
<a id="@ManageNavPages.U2F.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-action="APIKeys">API Keys</a>
</div>