Part3: OpenIddict: Add Flows Event Handlers (#568)

* Part 1 & Part 2 squashed commits

pr changes


pr fixes


remove config for openid -- no need for it for now


Part 1: OpenIddict - Minor Changes & Config prep


Part2: Openiddict: Init OpenIddict & Database Migration & Auth Policies


pr changes


fix merge 


fix compile


fix compile #2


Part 1: OpenIddict - Minor Changes & Config prep


add missing nuget


Part2: Openiddict: Init OpenIddict & Database Migration & Auth Policies

* Part3: OpenIddict: Add Flows Event Handlers

* pr changes

* fix merge

* fix rebase

* fix imports

* cleanup

* do not allow u2f enabled accounts to log in

* start better tests for flows

* add tests

* fixes

* reintroduce dynamic policy as policies on jwt do not work without it

* reduce logs

* fix incorrect endpoint definitions

* Add implicit flow e2e test

* add code flow and refresh flow

* do not allow jwt bearer auth for all requests( only those under /api)

* remove commentedt code

* make sure authorize attr is marked with scheme

* remove dynamic policy and set claims in jwt handler

* cleanup

* change serversettings policy to not need a claim

* Add test to checkadmin verification

* revert server setting claim removal

* fix test

* switch back to claim

* unit test fixes

* try fix build with weird references to csprojes

* start fixing rebase

* remove https requirement to handle tor

* reformat tests correctly

* fix csproj

* fix ut formatting

* PR Changes

* do not show selenium browser
This commit is contained in:
Andrew Camilleri
2019-07-01 05:39:25 +02:00
committed by Nicolas Dorier
parent 442df56629
commit 4055eda757
19 changed files with 1003 additions and 60 deletions

View File

@@ -0,0 +1,368 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AspNet.Security.OpenIdConnect.Primitives;
using BTCPayServer.Tests.Logging;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Xunit;
using Xunit.Abstractions;
using System.Net.Http;
using System.Net.Http.Headers;
using BTCPayServer.Data;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenIddict.Abstractions;
using OpenQA.Selenium;
namespace BTCPayServer.Tests
{
public class AuthenticationTests
{
public AuthenticationTests(ITestOutputHelper helper)
{
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact]
[Trait("Integration", "Integration")]
public async Task GetRedirectedToLoginPathOnChallenge()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var client = tester.PayTester.HttpClient;
//Wallets endpoint is protected
var response = await client.GetAsync("wallets");
var urlPath = response.RequestMessage.RequestUri.ToString()
.Replace(tester.PayTester.ServerUri.ToString(), "");
//Cookie Challenge redirects you to login page
Assert.StartsWith("Account/Login", urlPath, StringComparison.InvariantCultureIgnoreCase);
var queryString = response.RequestMessage.RequestUri.ParseQueryString();
Assert.NotNull(queryString["ReturnUrl"]);
Assert.Equal("/wallets", queryString["ReturnUrl"]);
}
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanGetOpenIdConfiguration()
{
using (var tester = ServerTester.Create())
{
tester.Start();
using (var response =
await tester.PayTester.HttpClient.GetAsync("/.well-known/openid-configuration"))
{
using (var streamToReadFrom = new StreamReader(await response.Content.ReadAsStreamAsync()))
{
var json = await streamToReadFrom.ReadToEndAsync();
Assert.NotNull(json);
var configuration = OpenIdConnectConfiguration.Create(json);
Assert.NotNull(configuration);
}
}
}
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUseNonInteractiveFlows()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
var token = await RegisterPasswordClientAndGetAccessToken(user, null, tester);
await TestApiAgainstAccessToken(token, tester, user);
token = await RegisterPasswordClientAndGetAccessToken(user, "secret", tester);
await TestApiAgainstAccessToken(token, tester, user);
token = await RegisterClientCredentialsFlowAndGetAccessToken(user, "secret", tester);
await TestApiAgainstAccessToken(token, tester, user);
}
}
[Trait("Selenium", "Selenium")]
[Fact]
public async Task CanUseImplicitFlow()
{
using (var s = SeleniumTester.Create())
{
s.Start();
var tester = s.Server;
var user = tester.NewAccount();
user.GrantAccess();
var id = Guid.NewGuid().ToString();
var redirecturi = new Uri("http://127.0.0.1/oidc-callback");
var openIdClient = await user.RegisterOpenIdClient(
new OpenIddictApplicationDescriptor()
{
ClientId = id,
DisplayName = id,
Permissions = {OpenIddictConstants.Permissions.GrantTypes.Implicit},
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()}");
s.Driver.Navigate().GoToUrl(implicitAuthorizeUrl);
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
var url = s.Driver.Url;
var results = url.Split("#").Last().Split("&")
.ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
await TestApiAgainstAccessToken(results["access_token"], tester, user);
//in Implicit mode, you renew your token by hitting the same endpoint but adding prompt=none. If you are still logged in on the site, you will receive a fresh token.
var implicitAuthorizeUrlSilentModel = new Uri($"{implicitAuthorizeUrl.OriginalString}&prompt=none");
s.Driver.Navigate().GoToUrl(implicitAuthorizeUrl);
url = s.Driver.Url;
results = url.Split("#").Last().Split("&").ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
await TestApiAgainstAccessToken(results["access_token"], tester, user);
await LogoutFlow(tester, id, s);
}
}
private async Task LogoutFlow(ServerTester tester, string clientId, SeleniumTester seleniumTester )
{
var logoutUrl = new Uri(tester.PayTester.ServerUri,
$"connect/logout?response_type=token&client_id={clientId}");
seleniumTester.Driver.Navigate().GoToUrl(logoutUrl);
seleniumTester.GoToHome();
Assert.Throws<NoSuchElementException>(() => seleniumTester.Driver.FindElement(By.Id("Logout")));
}
[Trait("Selenium", "Selenium")]
[Fact]
public async Task CanUseCodeFlow()
{
using (var s = SeleniumTester.Create())
{
s.Start();
var tester = s.Server;
var user = tester.NewAccount();
user.GrantAccess();
var id = Guid.NewGuid().ToString();
var redirecturi = new Uri("http://127.0.0.1/oidc-callback");
var secret = "secret";
var openIdClient = await user.RegisterOpenIdClient(
new OpenIddictApplicationDescriptor()
{
ClientId = id,
DisplayName = id,
Permissions =
{
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken
},
RedirectUris = {redirecturi}
}, secret);
var authorizeUrl = new Uri(tester.PayTester.ServerUri,
$"connect/authorize?response_type=code&client_id={id}&redirect_uri={redirecturi.AbsoluteUri}&scope=openid offline_access&state={Guid.NewGuid().ToString()}");
s.Driver.Navigate().GoToUrl(authorizeUrl);
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
var url = s.Driver.Url;
var results = url.Split("?").Last().Split("&")
.ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
var httpClient = tester.PayTester.HttpClient;
var httpRequest = new HttpRequestMessage(HttpMethod.Post,
new Uri(tester.PayTester.ServerUri, "/connect/token"))
{
Content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>()
{
new KeyValuePair<string, string>("grant_type",
OpenIddictConstants.GrantTypes.AuthorizationCode),
new KeyValuePair<string, string>("client_id", openIdClient.ClientId),
new KeyValuePair<string, string>("client_secret", secret),
new KeyValuePair<string, string>("code", results["code"]),
new KeyValuePair<string, string>("redirect_uri", redirecturi.AbsoluteUri)
})
};
var response = await httpClient.SendAsync(httpRequest);
Assert.True(response.IsSuccessStatusCode);
string content = await response.Content.ReadAsStringAsync();
var result = JObject.Parse(content).ToObject<OpenIdConnectResponse>();
await TestApiAgainstAccessToken(result.AccessToken, tester, user);
var refreshedAccessToken = await RefreshAnAccessToken(result.RefreshToken, httpClient, id, secret);
await TestApiAgainstAccessToken(refreshedAccessToken, tester, user);
}
}
private static async Task<string> RefreshAnAccessToken(string refreshToken, HttpClient client, string clientId,
string clientSecret = null)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Post,
new Uri(client.BaseAddress, "/connect/token"))
{
Content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>()
{
new KeyValuePair<string, string>("grant_type",
OpenIddictConstants.GrantTypes.RefreshToken),
new KeyValuePair<string, string>("client_id", clientId),
new KeyValuePair<string, string>("client_secret", clientSecret),
new KeyValuePair<string, string>("refresh_token", refreshToken)
})
};
var response = await client.SendAsync(httpRequest);
Assert.True(response.IsSuccessStatusCode);
string content = await response.Content.ReadAsStringAsync();
var result = JObject.Parse(content).ToObject<OpenIdConnectResponse>();
Assert.NotEmpty(result.AccessToken);
Assert.Null(result.Error);
return result.AccessToken;
}
private static async Task<string> RegisterClientCredentialsFlowAndGetAccessToken(TestAccount user,
string secret,
ServerTester tester)
{
var id = Guid.NewGuid().ToString();
var openIdClient = await user.RegisterOpenIdClient(
new OpenIddictApplicationDescriptor()
{
ClientId = id,
DisplayName = id,
Permissions = {OpenIddictConstants.Permissions.GrantTypes.ClientCredentials}
}, secret);
var httpClient = tester.PayTester.HttpClient;
var httpRequest = new HttpRequestMessage(HttpMethod.Post,
new Uri(tester.PayTester.ServerUri, "/connect/token"))
{
Content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>()
{
new KeyValuePair<string, string>("grant_type",
OpenIddictConstants.GrantTypes.ClientCredentials),
new KeyValuePair<string, string>("client_id", openIdClient.ClientId),
new KeyValuePair<string, string>("client_secret", secret)
})
};
var response = await httpClient.SendAsync(httpRequest);
Assert.True(response.IsSuccessStatusCode);
string content = await response.Content.ReadAsStringAsync();
var result = JObject.Parse(content).ToObject<OpenIdConnectResponse>();
Assert.NotEmpty(result.AccessToken);
Assert.Null(result.Error);
return result.AccessToken;
}
private static async Task<string> RegisterPasswordClientAndGetAccessToken(TestAccount user, string secret,
ServerTester tester)
{
var id = Guid.NewGuid().ToString();
var openIdClient = await user.RegisterOpenIdClient(
new OpenIddictApplicationDescriptor()
{
ClientId = id,
DisplayName = id,
Permissions = {OpenIddictConstants.Permissions.GrantTypes.Password}
}, secret);
var httpClient = tester.PayTester.HttpClient;
var httpRequest = new HttpRequestMessage(HttpMethod.Post,
new Uri(tester.PayTester.ServerUri, "/connect/token"))
{
Content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>()
{
new KeyValuePair<string, string>("grant_type", OpenIddictConstants.GrantTypes.Password),
new KeyValuePair<string, string>("username", user.RegisterDetails.Email),
new KeyValuePair<string, string>("password", user.RegisterDetails.Password),
new KeyValuePair<string, string>("client_id", openIdClient.ClientId),
new KeyValuePair<string, string>("client_secret", secret)
})
};
var response = await httpClient.SendAsync(httpRequest);
Assert.True(response.IsSuccessStatusCode);
string content = await response.Content.ReadAsStringAsync();
var result = JObject.Parse(content).ToObject<OpenIdConnectResponse>();
Assert.NotEmpty(result.AccessToken);
Assert.Null(result.Error);
return result.AccessToken;
}
public async Task TestApiAgainstAccessToken(string accessToken, ServerTester tester, TestAccount testAccount)
{
var resultUser =
await TestApiAgainstAccessToken<string>(accessToken, "api/test/me/id",
tester.PayTester.HttpClient);
Assert.Equal(testAccount.UserId, resultUser);
var secondUser = tester.NewAccount();
secondUser.GrantAccess();
var resultStores =
await TestApiAgainstAccessToken<StoreData[]>(accessToken, "api/test/me/stores",
tester.PayTester.HttpClient);
Assert.Contains(resultStores,
data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase));
Assert.DoesNotContain(resultStores,
data => data.Id.Equals(secondUser.StoreId, StringComparison.InvariantCultureIgnoreCase));
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
$"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));
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(accessToken, $"api/test/me/stores/{secondUser.StoreId}/can-edit",
tester.PayTester.HttpClient);
});
}
public async Task<T> TestApiAgainstAccessToken<T>(string accessToken, string url, HttpClient client)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get,
new Uri(client.BaseAddress, url));
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var result = await client.SendAsync(httpRequest);
result.EnsureSuccessStatusCode();
var rawJson = await result.Content.ReadAsStringAsync();
if (typeof(T).IsPrimitive || typeof(T) == typeof(string))
{
return (T)Convert.ChangeType(rawJson, typeof(T));
}
return JsonConvert.DeserializeObject<T>(rawJson);
}
}
}

View File

@@ -1,4 +1,4 @@
using BTCPayServer.Configuration;
using BTCPayServer.Configuration;
using System.Linq;
using BTCPayServer.HostedServices;
using BTCPayServer.Hosting;

View File

@@ -130,14 +130,22 @@ namespace BTCPayServer.Tests
Assert.Contains("Status Code: 404; Not Found", Driver.PageSource);
}
internal void GoToHome()
public void GoToHome()
{
Driver.Navigate().GoToUrl(Server.PayTester.ServerUri);
}
internal void Logout()
public void Logout()
{
Driver.FindElement(By.Id("Logout")).Click();
}
public void Login(string user, string password)
{
Driver.FindElement(By.Id("Email")).SendKeys(user);
Driver.FindElement(By.Id("Password")).SendKeys(password);
Driver.FindElement(By.Id("LoginButton")).Click();
}
}
}

View File

@@ -86,7 +86,7 @@ namespace BTCPayServer.Tests
}
}
private static void LogIn(SeleniumTester s, string email)
public static void LogIn(SeleniumTester s, string email)
{
s.Driver.FindElement(By.Id("Login")).Click();
s.Driver.FindElement(By.Id("Email")).SendKeys(email);
@@ -166,7 +166,7 @@ namespace BTCPayServer.Tests
}
}
private static void CreateInvoice(SeleniumTester s, string store)
public static void CreateInvoice(SeleniumTester s, string store)
{
s.Driver.FindElement(By.Id("Invoices")).Click();
s.Driver.FindElement(By.Id("CreateNewInvoice")).Click();

View File

@@ -10,6 +10,7 @@ using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Authentication.OpenId.Models;
using Xunit;
using NBXplorer.DerivationStrategy;
using BTCPayServer.Payments;
@@ -18,6 +19,8 @@ using BTCPayServer.Tests.Logging;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning;
using BTCPayServer.Data;
using OpenIddict.Abstractions;
using OpenIddict.Core;
namespace BTCPayServer.Tests
{
@@ -166,5 +169,14 @@ namespace BTCPayServer.Tests
if (storeController.ModelState.ErrorCount != 0)
Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage);
}
public async Task<BTCPayOpenIdClient> RegisterOpenIdClient(OpenIddictApplicationDescriptor descriptor, string secret = null)
{
var openIddictApplicationManager = parent.PayTester.GetService<OpenIddictApplicationManager<BTCPayOpenIdClient>>();
var client = new BTCPayOpenIdClient {ApplicationUserId = UserId};
await openIddictApplicationManager.PopulateAsync(client, descriptor);
await openIddictApplicationManager.CreateAsync(client, secret);
return client;
}
}
}

View File

@@ -262,11 +262,12 @@ namespace BTCPayServer.Tests
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Coins(1.3m), accounting.TotalDue);
entity.Payments.Add(new PaymentEntity()
{
Output = new TxOut(Money.Coins(0.2m), new Key()),
Accounted = true
});
entity.Payments.Add(
new PaymentEntity()
{
Output = new TxOut(Money.Coins(0.2m), new Key()),
Accounted = true
});
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
@@ -276,9 +277,19 @@ namespace BTCPayServer.Tests
entity.ProductInformation = new ProductInformation() {Price = 5000};
PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary();
paymentMethods.Add(
new PaymentMethod() {CryptoCode = "BTC", Rate = 1000, NextNetworkFee = Money.Coins(0.1m)});
new PaymentMethod()
{
CryptoCode = "BTC",
Rate = 1000,
NextNetworkFee = Money.Coins(0.1m)
});
paymentMethods.Add(
new PaymentMethod() {CryptoCode = "LTC", Rate = 500, NextNetworkFee = Money.Coins(0.01m)});
new PaymentMethod()
{
CryptoCode = "LTC",
Rate = 500,
NextNetworkFee = Money.Coins(0.01m)
});
entity.SetPaymentMethods(paymentMethods);
entity.Payments = new List<PaymentEntity>();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike));
@@ -368,29 +379,6 @@ namespace BTCPayServer.Tests
#pragma warning restore CS0618
}
[Fact]
[Trait("Integration", "Integration")]
public async Task GetRedirectedToLoginPathOnChallenge()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var client = tester.PayTester.HttpClient;
//Wallets endpoint is protected
var response = await client.GetAsync("wallets");
var urlPath = response.RequestMessage.RequestUri.ToString()
.Replace(tester.PayTester.ServerUri.ToString(), "");
//Cookie Challenge redirects you to login page
Assert.StartsWith("Account/Login", urlPath, StringComparison.InvariantCultureIgnoreCase);
var queryString = response.RequestMessage.RequestUri.ParseQueryString();
Assert.NotNull(queryString["ReturnUrl"]);
Assert.Equal("/wallets", queryString["ReturnUrl"]);
}
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUseTestWebsiteUI()
@@ -398,8 +386,7 @@ namespace BTCPayServer.Tests
using (var tester = ServerTester.Create())
{
tester.Start();
var http = new HttpClient();
var response = await http.GetAsync(tester.PayTester.ServerUri);
var response = await tester.PayTester.HttpClient.GetAsync("");
Assert.True(response.IsSuccessStatusCode);
}
}

View File

@@ -0,0 +1,21 @@
using AspNet.Security.OpenIdConnect.Primitives;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Authentication.OpenId
{
public class AuthorizationCodeGrantTypeEventHandler : OpenIdGrantHandlerCheckCanSignIn
{
public AuthorizationCodeGrantTypeEventHandler(SignInManager<ApplicationUser> signInManager,
IOptions<IdentityOptions> identityOptions, UserManager<ApplicationUser> userManager) : base(signInManager,
identityOptions, userManager)
{
}
protected override bool IsValid(OpenIdConnectRequest request)
{
return request.IsAuthorizationCodeGrantType();
}
}
}

View File

@@ -0,0 +1,78 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AspNet.Security.OpenIdConnect.Primitives;
using BTCPayServer.Models;
using BTCPayServer.Security;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
namespace BTCPayServer.Authentication.OpenId
{
public class AuthorizationEventHandler : BaseOpenIdGrantHandler<OpenIddictServerEvents.HandleAuthorizationRequest>
{
private readonly UserManager<ApplicationUser> _userManager;
public override async Task<OpenIddictServerEventState> HandleAsync(
OpenIddictServerEvents.HandleAuthorizationRequest notification)
{
if (!notification.Context.Request.IsAuthorizationRequest())
{
return OpenIddictServerEventState.Unhandled;
}
var auth = await notification.Context.HttpContext.AuthenticateAsync();
if (!auth.Succeeded)
{
// If the client application request promptless authentication,
// return an error indicating that the user is not logged in.
if (notification.Context.Request.HasPrompt(OpenIdConnectConstants.Prompts.None))
{
var properties = new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIdConnectConstants.Properties.Error] = OpenIdConnectConstants.Errors.LoginRequired,
[OpenIdConnectConstants.Properties.ErrorDescription] = "The user is not logged in."
});
// Ask OpenIddict to return a login_required error to the client application.
await notification.Context.HttpContext.ForbidAsync(properties);
notification.Context.HandleResponse();
return OpenIddictServerEventState.Handled;
}
await notification.Context.HttpContext.ChallengeAsync();
notification.Context.HandleResponse();
return OpenIddictServerEventState.Handled;
}
// Retrieve the profile of the logged in user.
var user = await _userManager.GetUserAsync(auth.Principal);
if (user == null)
{
notification.Context.Reject(
error: OpenIddictConstants.Errors.InvalidGrant,
description: "An internal error has occurred");
return OpenIddictServerEventState.Handled;
}
// Create a new authentication ticket.
var ticket = await CreateTicketAsync(notification.Context.Request, user);
// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
notification.Context.Validate(ticket);
return OpenIddictServerEventState.Handled;
}
public AuthorizationEventHandler(
UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager,
IOptions<IdentityOptions> identityOptions) : base(signInManager, identityOptions)
{
_userManager = userManager;
}
}
}

View File

@@ -0,0 +1,104 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using AspNet.Security.OpenIdConnect.Extensions;
using AspNet.Security.OpenIdConnect.Primitives;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
namespace BTCPayServer.Authentication.OpenId
{
public abstract class BaseOpenIdGrantHandler<T> : IOpenIddictServerEventHandler<T>
where T : class, IOpenIddictServerEvent
{
protected readonly SignInManager<ApplicationUser> _signInManager;
protected readonly IOptions<IdentityOptions> _identityOptions;
protected BaseOpenIdGrantHandler(SignInManager<ApplicationUser> signInManager,
IOptions<IdentityOptions> identityOptions)
{
_signInManager = signInManager;
_identityOptions = identityOptions;
}
protected async Task<AuthenticationTicket> CreateTicketAsync(
OpenIdConnectRequest request, ApplicationUser user,
AuthenticationProperties properties = null)
{
// Create a new ClaimsPrincipal containing the claims that
// will be used to create an id_token, a token or a code.
var principal = await _signInManager.CreateUserPrincipalAsync(user);
// Create a new authentication ticket holding the user identity.
var ticket = new AuthenticationTicket(principal, properties,
OpenIddictServerDefaults.AuthenticationScheme);
if (!request.IsAuthorizationCodeGrantType() && !request.IsRefreshTokenGrantType())
{
// Note: in this sample, the granted scopes match the requested scope
// but you may want to allow the user to uncheck specific scopes.
// For that, simply restrict the list of scopes before calling SetScopes.
ticket.SetScopes(request.GetScopes());
}
foreach (var claim in ticket.Principal.Claims)
{
claim.SetDestinations(GetDestinations(claim, ticket));
}
return ticket;
}
private IEnumerable<string> GetDestinations(Claim claim, AuthenticationTicket ticket)
{
// Note: by default, claims are NOT automatically included in the access and identity tokens.
// To allow OpenIddict to serialize them, you must attach them a destination, that specifies
// whether they should be included in access tokens, in identity tokens or in both.
switch (claim.Type)
{
case OpenIddictConstants.Claims.Name:
yield return OpenIddictConstants.Destinations.AccessToken;
if (ticket.HasScope(OpenIddictConstants.Scopes.Profile))
yield return OpenIddictConstants.Destinations.IdentityToken;
yield break;
case OpenIddictConstants.Claims.Email:
yield return OpenIddictConstants.Destinations.AccessToken;
if (ticket.HasScope(OpenIddictConstants.Scopes.Email))
yield return OpenIddictConstants.Destinations.IdentityToken;
yield break;
case OpenIddictConstants.Claims.Role:
yield return OpenIddictConstants.Destinations.AccessToken;
if (ticket.HasScope(OpenIddictConstants.Scopes.Roles))
yield return OpenIddictConstants.Destinations.IdentityToken;
yield break;
default:
if (claim.Type == _identityOptions.Value.ClaimsIdentity.SecurityStampClaimType)
{
// Never include the security stamp in the access and identity tokens, as it's a secret value.
yield break;
}
else
{
yield return OpenIddictConstants.Destinations.AccessToken;
yield break;
}
}
}
public abstract Task<OpenIddictServerEventState> HandleAsync(T notification);
}
}

View File

@@ -0,0 +1,61 @@
using System.Security.Claims;
using System.Threading.Tasks;
using AspNet.Security.OpenIdConnect.Extensions;
using AspNet.Security.OpenIdConnect.Primitives;
using BTCPayServer.Authentication.OpenId.Models;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Core;
using OpenIddict.EntityFrameworkCore.Models;
using OpenIddict.Server;
namespace BTCPayServer.Authentication.OpenId
{
public class
ClientCredentialsGrantTypeEventHandler : BaseOpenIdGrantHandler<OpenIddictServerEvents.HandleTokenRequest>
{
private readonly OpenIddictApplicationManager<BTCPayOpenIdClient> _applicationManager;
private readonly UserManager<ApplicationUser> _userManager;
public ClientCredentialsGrantTypeEventHandler(SignInManager<ApplicationUser> signInManager,
OpenIddictApplicationManager<BTCPayOpenIdClient> applicationManager,
IOptions<IdentityOptions> identityOptions, UserManager<ApplicationUser> userManager) : base(signInManager,
identityOptions)
{
_applicationManager = applicationManager;
_userManager = userManager;
}
public override async Task<OpenIddictServerEventState> HandleAsync(
OpenIddictServerEvents.HandleTokenRequest notification)
{
var request = notification.Context.Request;
if (!request.IsClientCredentialsGrantType())
{
// Allow other handlers to process the event.
return OpenIddictServerEventState.Unhandled;
}
var application = await _applicationManager.FindByClientIdAsync(request.ClientId,
notification.Context.HttpContext.RequestAborted);
if (application == null)
{
notification.Context.Reject(
error: OpenIddictConstants.Errors.InvalidClient,
description: "The client application was not found in the database.");
// Don't allow other handlers to process the event.
return OpenIddictServerEventState.Handled;
}
var user = await _userManager.FindByIdAsync(application.ApplicationUserId);
notification.Context.Validate(await CreateTicketAsync(request, user));
// Don't allow other handlers to process the event.
return OpenIddictServerEventState.Handled;
}
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Server;
namespace BTCPayServer.Authentication.OpenId
{
public class LogoutEventHandler: BaseOpenIdGrantHandler<OpenIddictServerEvents.HandleLogoutRequest>
{
public LogoutEventHandler(SignInManager<ApplicationUser> signInManager, IOptions<IdentityOptions> identityOptions) : base(signInManager, identityOptions)
{
}
public override async Task<OpenIddictServerEventState> HandleAsync(OpenIddictServerEvents.HandleLogoutRequest notification)
{
// Ask ASP.NET Core Identity to delete the local and external cookies created
// when the user agent is redirected from the external identity provider
// after a successful authentication flow (e.g Google or Facebook).
await _signInManager.SignOutAsync();
// Returning a SignOutResult will ask OpenIddict to redirect the user agent
// to the post_logout_redirect_uri specified by the client application.
await notification.Context.HttpContext.SignOutAsync(OpenIddictServerDefaults.AuthenticationScheme);
notification.Context.HandleResponse();
return OpenIddictServerEventState.Handled;
}
}
}

View File

@@ -0,0 +1,65 @@
using System.Threading.Tasks;
using AspNet.Security.OpenIdConnect.Primitives;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
namespace BTCPayServer.Authentication.OpenId
{
public abstract class
OpenIdGrantHandlerCheckCanSignIn : BaseOpenIdGrantHandler<OpenIddictServerEvents.HandleTokenRequest>
{
private readonly UserManager<ApplicationUser> _userManager;
protected OpenIdGrantHandlerCheckCanSignIn(SignInManager<ApplicationUser> signInManager,
IOptions<IdentityOptions> identityOptions, UserManager<ApplicationUser> userManager) : base(signInManager,
identityOptions)
{
_userManager = userManager;
}
protected abstract bool IsValid(OpenIdConnectRequest request);
public override async Task<OpenIddictServerEventState> HandleAsync(
OpenIddictServerEvents.HandleTokenRequest notification)
{
var request = notification.Context.Request;
if (!IsValid(request))
{
// Allow other handlers to process the event.
return OpenIddictServerEventState.Unhandled;
}
var scheme = notification.Context.Scheme.Name;
var authenticateResult = (await notification.Context.HttpContext.AuthenticateAsync(scheme));
var user = await _userManager.GetUserAsync(authenticateResult.Principal);
if (user == null)
{
notification.Context.Reject(
error: OpenIddictConstants.Errors.InvalidGrant,
description: "The token is no longer valid.");
// Don't allow other handlers to process the event.
return OpenIddictServerEventState.Handled;
}
// Ensure the user is still allowed to sign in.
if (!await _signInManager.CanSignInAsync(user))
{
notification.Context.Reject(
error: OpenIddictConstants.Errors.InvalidGrant,
description: "The user is no longer allowed to sign in.");
// Don't allow other handlers to process the event.
return OpenIddictServerEventState.Handled;
}
notification.Context.Validate(await CreateTicketAsync(request, user));
// Don't allow other handlers to process the event.
return OpenIddictServerEventState.Handled;
}
}
}

View File

@@ -0,0 +1,57 @@
using System.Threading.Tasks;
using AspNet.Security.OpenIdConnect.Primitives;
using BTCPayServer.Models;
using BTCPayServer.Services.U2F;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
namespace BTCPayServer.Authentication.OpenId
{
public class PasswordGrantTypeEventHandler : BaseOpenIdGrantHandler<OpenIddictServerEvents.HandleTokenRequest>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly U2FService _u2FService;
public PasswordGrantTypeEventHandler(SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
IOptions<IdentityOptions> identityOptions, U2FService u2FService) : base(signInManager, identityOptions)
{
_userManager = userManager;
_u2FService = u2FService;
}
public override async Task<OpenIddictServerEventState> HandleAsync(
OpenIddictServerEvents.HandleTokenRequest notification)
{
var request = notification.Context.Request;
if (!request.IsPasswordGrantType())
{
// Allow other handlers to process the event.
return OpenIddictServerEventState.Unhandled;
}
// Validate the user credentials.
// Note: to mitigate brute force attacks, you SHOULD strongly consider
// applying a key derivation function like PBKDF2 to slow down
// the password validation process. You SHOULD also consider
// using a time-constant comparer to prevent timing attacks.
var user = await _userManager.FindByNameAsync(request.Username);
if (user == null || await _u2FService.HasDevices(user.Id) ||
!(await _signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true))
.Succeeded)
{
notification.Context.Reject(
error: OpenIddictConstants.Errors.InvalidGrant,
description: "The specified credentials are invalid.");
// Don't allow other handlers to process the event.
return OpenIddictServerEventState.Handled;
}
notification.Context.Validate(await CreateTicketAsync(request, user));
// Don't allow other handlers to process the event.
return OpenIddictServerEventState.Handled;
}
}
}

View File

@@ -0,0 +1,21 @@
using AspNet.Security.OpenIdConnect.Primitives;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Authentication.OpenId
{
public class RefreshTokenGrantTypeEventHandler : OpenIdGrantHandlerCheckCanSignIn
{
public RefreshTokenGrantTypeEventHandler(SignInManager<ApplicationUser> signInManager,
IOptions<IdentityOptions> identityOptions, UserManager<ApplicationUser> userManager) : base(signInManager,
identityOptions, userManager)
{
}
protected override bool IsValid(OpenIdConnectRequest request)
{
return request.IsRefreshTokenGrantType();
}
}
}

View File

@@ -0,0 +1,61 @@
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Security;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Validation;
namespace BTCPayServer.Controllers.RestApi
{
/// <summary>
/// this controller serves as a testing endpoint for our OpenId unit tests
/// </summary>
[Route("api/[controller]")]
[ApiController]
[Authorize(AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
public class TestController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly StoreRepository _storeRepository;
public TestController(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")]
public bool AmIAnAdmin()
{
return User.IsInRole(Roles.ServerAdmin);
}
[HttpGet("me/stores")]
public async Task<StoreData[]> GetCurrentUserStores()
{
return await _storeRepository.GetStoresByUserId(_userManager.GetUserId(User));
}
[HttpGet("me/stores/{storeId}/can-edit")]
[Authorize(Policy = Policies.CanModifyStoreSettings.Key, AuthenticationSchemes = OpenIddictValidationDefaults.AuthenticationScheme)]
public bool CanEdit(string storeId)
{
return true;
}
}
}

View File

@@ -10,28 +10,36 @@ namespace BTCPayServer
{
public static class OpenIddictExtensions
{
public static OpenIddictServerBuilder ConfigureSigningKey(this OpenIddictServerBuilder builder,
IConfiguration configuration)
private static SecurityKey _key = null;
public static SecurityKey GetSigningKey(IConfiguration configuration)
{
if (_key != null)
{
return _key;
}
var file = Path.Combine(configuration.GetDataDir(), "rsaparams");
RSACryptoServiceProvider RSA = new RSACryptoServiceProvider(2048);
RsaSecurityKey key = null;
if (File.Exists(file))
{
RSA.FromXmlString2( File.ReadAllText(file));
RSA.FromXmlString2(File.ReadAllText(file));
}
else
{
var contents = RSA.ToXmlString2(true);
File.WriteAllText(file,contents );
File.WriteAllText(file, contents);
}
RSAParameters KeyParam = RSA.ExportParameters(true);
key = new RsaSecurityKey(KeyParam);
return builder.AddSigningKey(key);
_key = new RsaSecurityKey(KeyParam);
return _key;
}
public static OpenIddictServerBuilder ConfigureSigningKey(this OpenIddictServerBuilder builder,
IConfiguration configuration)
{
return builder.AddSigningKey(GetSigningKey(configuration));
}
}
}

View File

@@ -22,14 +22,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using BTCPayServer.Controllers;
using BTCPayServer.Services.Mails;
using Microsoft.AspNetCore.Identity;
using System.Threading;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Authentication;
using BTCPayServer.Authentication.OpenId.Models;
using BTCPayServer.Logging;
using BTCPayServer.HostedServices;
using System.Security.Claims;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
@@ -37,16 +35,25 @@ using BTCPayServer.Payments.Changelly;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security;
using BTCPayServer.Services.PaymentRequests;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBXplorer.DerivationStrategy;
using NicolasDorier.RateLimits;
using Npgsql;
using BTCPayServer.Services.Apps;
using OpenIddict.EntityFrameworkCore.Models;
using BTCPayServer.Services.U2F;
using BundlerMinifier.TagHelpers;
using OpenIddict.EntityFrameworkCore.Models;
using System.Collections.Generic;
using System.Diagnostics;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Hosting
{
public static class BTCPayServerServices
@@ -247,8 +254,9 @@ namespace BTCPayServer.Hosting
services.AddSingleton(rateLimits);
return services;
}
private static void AddBtcPayServerAuthenticationSchemes(this IServiceCollection services, IConfiguration configuration)
private static void AddBtcPayServerAuthenticationSchemes(this IServiceCollection services,
IConfiguration configuration)
{
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear();
@@ -256,9 +264,51 @@ namespace BTCPayServer.Hosting
services.AddAuthentication()
.AddJwtBearer(options =>
{
// options.RequireHttpsMetadata = false;
// options.TokenValidationParameters.ValidateAudience = false;
//Disabled so that Tor works witt JWT auth
options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false;
//we do not validate the issuer directly because btcpay can be accessed through multiple urls that we cannot predetermine
options.TokenValidationParameters.ValidateIssuer = false;
options.TokenValidationParameters.IssuerSigningKey =
OpenIddictExtensions.GetSigningKey(configuration);
options.IncludeErrorDetails = true;
options.Events = new JwtBearerEvents()
{
OnTokenValidated = async context =>
{
var routeData = context.HttpContext.GetRouteData();
var identity = ((ClaimsIdentity)context.Principal.Identity);
if (context.Principal.IsInRole(Roles.ServerAdmin))
{
identity.AddClaim(new Claim(Policies.CanModifyServerSettings.Key, "true"));
}
if (context.HttpContext.GetStoreData() != null ||
!routeData.Values.TryGetValue("storeId", out var storeId))
{
return;
}
var userManager = context.HttpContext.RequestServices
.GetService<UserManager<ApplicationUser>>();
var storeRepository = context.HttpContext.RequestServices
.GetService<StoreRepository>();
var userid = userManager.GetUserId(context.Principal);
if (!string.IsNullOrEmpty(userid))
{
var store = await storeRepository.FindStore((string)storeId, userid);
if (store == null)
{
context.Fail("Could not authorize you against store access");
}
else
{
context.HttpContext.SetStoreData(store);
identity.AddClaims(store.GetClaims());
}
}
}
};
})
.AddCookie()
.AddBitpayAuthentication();

View File

@@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core;
using OpenIddict.Abstractions;
using OpenIddict.EntityFrameworkCore.Models;
using System.Net;
using BTCPayServer.Authentication.OpenId;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Services.Apps;
using BTCPayServer.Storage;
@@ -142,6 +143,9 @@ namespace BTCPayServer.Hosting
})
.AddServer(options =>
{
//Disabled so that Tor works with OpenIddict too
options.DisableHttpsRequirement();
// Register the ASP.NET Core MVC binder used by OpenIddict.
// Note: if you don't call this method, you won't be able to
// bind OpenIdConnectRequest or OpenIdConnectResponse parameters.
@@ -150,8 +154,11 @@ namespace BTCPayServer.Hosting
// Enable the token endpoint (required to use the password flow).
options.EnableTokenEndpoint("/connect/token");
options.EnableAuthorizationEndpoint("/connect/authorize");
options.EnableAuthorizationEndpoint("/connect/logout");
options.EnableLogoutEndpoint("/connect/logout");
//we do not care about these granular controls for now
options.DisableScopeValidation();
options.IgnoreEndpointPermissions();
// Allow client applications various flows
options.AllowImplicitFlow();
options.AllowClientCredentialsFlow();
@@ -167,6 +174,12 @@ namespace BTCPayServer.Hosting
OpenIdConnectConstants.Scopes.Email,
OpenIdConnectConstants.Scopes.Profile,
OpenIddictConstants.Scopes.Roles);
options.AddEventHandler<PasswordGrantTypeEventHandler>();
options.AddEventHandler<AuthorizationCodeGrantTypeEventHandler>();
options.AddEventHandler<RefreshTokenGrantTypeEventHandler>();
options.AddEventHandler<ClientCredentialsGrantTypeEventHandler>();
options.AddEventHandler<AuthorizationEventHandler>();
options.AddEventHandler<LogoutEventHandler>();
options.ConfigureSigningKey(Configuration);
});

View File

@@ -1,4 +1,4 @@
using System.Security.Claims;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Models;
using BTCPayServer.Services.Stores;
@@ -52,10 +52,7 @@ namespace BTCPayServer.Security
else
{
context.HttpContext.SetStoreData(store);
if (store != null)
{
identity.AddClaims(store.GetClaims());
}
identity.AddClaims(store.GetClaims());
}
}
}