Skip to content

Commit

Permalink
Merge branch 'auth'
Browse files Browse the repository at this point in the history
  • Loading branch information
dennisreimann committed Apr 8, 2024
2 parents 87b7a1a + 592e65d commit 90089b6
Show file tree
Hide file tree
Showing 50 changed files with 1,343 additions and 57 deletions.
83 changes: 83 additions & 0 deletions BTCPayApp.Core/AsyncDuplicateLock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.Collections.Concurrent;

namespace BTCPayApp.Core;

///from https://stackoverflow.com/a/31194647/275504
public sealed class AsyncDuplicateLock
{
private sealed class RefCounted<T>
{
public RefCounted(T value)
{
RefCount = 1;
Value = value;
}

public int RefCount { get; set; }
public T Value { get; private set; }
}

private readonly ConcurrentDictionary<object, RefCounted<SemaphoreSlim>?> _semaphoreSlims = new();

private SemaphoreSlim GetOrCreate(object key)
{
RefCounted<SemaphoreSlim>? item;
lock (_semaphoreSlims)
{
if (_semaphoreSlims.TryGetValue(key, out item) && item is { })
{
++item.RefCount;
}
else
{
item = new RefCounted<SemaphoreSlim>(new SemaphoreSlim(1, 1));
_semaphoreSlims[key] = item;
}
}
return item.Value;
}

// get a lock for a specific key, and wait until it is available
public async Task<IDisposable> LockAsync(object key, CancellationToken cancellationToken = default)
{
await GetOrCreate(key).WaitAsync(cancellationToken).ConfigureAwait(false);
return new Releaser(_semaphoreSlims, key);
}

// get a lock for a specific key if it is available, or return null if it is currently locked
public async Task<IDisposable?> LockOrBustAsync(object key, CancellationToken cancellationToken = default)
{
var semaphore = GetOrCreate(key);
if (semaphore.CurrentCount == 0)
return null;
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
return new Releaser(_semaphoreSlims, key);
}
private sealed class Releaser : IDisposable
{
private readonly ConcurrentDictionary<object, RefCounted<SemaphoreSlim>?> _semaphoreSlims;

public Releaser(ConcurrentDictionary<object, RefCounted<SemaphoreSlim>?> semaphoreSlims, object key)
{
_semaphoreSlims = semaphoreSlims;
Key = key;
}

private object Key { get; set; }

public void Dispose()
{
RefCounted<SemaphoreSlim>? item;
lock (_semaphoreSlims)
{
if (_semaphoreSlims.TryGetValue(Key, out item) && item is { })
{
--item.RefCount;
if (item.RefCount == 0)
_semaphoreSlims.TryRemove(Key, out _);
}
}
item?.Value.Release();
}
}
}
15 changes: 15 additions & 0 deletions BTCPayApp.Core/BTCPayApp.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,19 @@
<ProjectReference Include="..\submodules\btcpayserver\BTCPayServer.Client\BTCPayServer.Client.csproj" />
</ItemGroup>

<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Authentication.BearerToken">
<HintPath>..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.AspNetCore.Authentication.BearerToken.dll</HintPath>
</Reference>
<Reference Include="Microsoft.AspNetCore.Components.Authorization">
<HintPath>..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.AspNetCore.Components.Authorization.dll</HintPath>
</Reference>
<Reference Include="Microsoft.AspNetCore.Http.Abstractions">
<HintPath>..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.AspNetCore.Http.Abstractions.dll</HintPath>
</Reference>
<Reference Include="Microsoft.AspNetCore.Identity">
<HintPath>..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.AspNetCore.Identity.dll</HintPath>
</Reference>
</ItemGroup>

</Project>
33 changes: 33 additions & 0 deletions BTCPayApp.Core/BTCPayServerAccount.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;

namespace BTCPayApp.Core;

public class BTCPayServerAccount(string baseUri, string email)
{
public Uri BaseUri { get; init; } = new (WithTrailingSlash(baseUri));
public string Email { get; init; } = email;
public string? AccessToken { get; set; }
public string? RefreshToken { get; set; }
public DateTimeOffset? AccessExpiry { get; set; }

[JsonConstructor]
public BTCPayServerAccount() : this(string.Empty, string.Empty) {}

public void SetAccess(string accessToken, string refreshToken, DateTimeOffset expiry)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
AccessExpiry = expiry;
}

public void ClearAccess()
{
AccessToken = null;
RefreshToken = null;
AccessExpiry = null;
}

private static string WithTrailingSlash(string str) =>
str.EndsWith("/", StringComparison.InvariantCulture) ? str : str + "/";
}

166 changes: 166 additions & 0 deletions BTCPayApp.Core/BTCPayServerAppApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Models;
using Microsoft.AspNetCore.Authentication.BearerToken;
using Microsoft.AspNetCore.Identity.Data;
using Microsoft.AspNetCore.Mvc;

namespace BTCPayApp.Core;

public class BTCPayServerAppApiClient(IHttpClientFactory clientFactory, ISecureConfigProvider secureConfigProvider)
{
private readonly HttpClient _httpClient = clientFactory.CreateClient();
private readonly string[] _unauthenticatedPaths = ["login", "forgot-password", "reset-password"];

private BTCPayServerAccount? Account { get; set; }

public async Task<(AccessTokenResult? success, string? errorCode)> Login(BTCPayServerAccount account, string password, string? otp = null, CancellationToken? cancellation = default)
{
Account = account;
var payload = new LoginRequest
{
Email = Account.Email,
Password = password,
TwoFactorCode = otp
};
try
{
var now = DateTimeOffset.Now;
var response = await Post<LoginRequest, AccessTokenResponse>("login", payload, cancellation.GetValueOrDefault());
var res = await HandleAccessTokenResponse(response!, now);
return (res, null);
}
catch (BTCPayServerClientException e)
{
return (null, e.Message);
}
}

private async Task<(AccessTokenResult? success, string? errorCode)> Refresh(BTCPayServerAccount account, CancellationToken? cancellation = default)
{
if (string.IsNullOrEmpty(account.RefreshToken)) throw new BTCPayServerClientException(422, "Account or Refresh Token missing");

Account = account;
var payload = new RefreshRequest
{
RefreshToken = Account.RefreshToken
};
try
{
var now = DateTimeOffset.Now;
var response = await Post<RefreshRequest, AccessTokenResponse>("refresh", payload, cancellation.GetValueOrDefault());
var res = await HandleAccessTokenResponse(response!, now);
return (res, null);
}
catch (BTCPayServerClientException e)
{
return (null, e.Message);
}
}

public async Task<AppUserInfoResult?> GetUserInfo(BTCPayServerAccount account, CancellationToken? cancellation = default)
{
Account = account;
return await Get<AppUserInfoResult>("info", cancellation.GetValueOrDefault());
}

public async Task<(bool success, string? errorCode)> ResetPassword(BTCPayServerAccount account, string? resetCode = null, string? newPassword = null, CancellationToken? cancellation = default)
{
Account = account;
var payload = new ResetPasswordRequest
{
Email = Account.Email,
ResetCode = resetCode ?? string.Empty,
NewPassword = newPassword ?? string.Empty
};
try
{
var path = string.IsNullOrEmpty(payload.ResetCode) && string.IsNullOrEmpty(payload.NewPassword)
? "forgot-password"
: "reset-password";
await Post<ResetPasswordRequest, EmptyResponseModel>(path, payload, cancellation.GetValueOrDefault());
return (true, null);
}
catch (BTCPayServerClientException e)
{
return (false, e.Message);
}
}

public void Logout()
{
_httpClient.DefaultRequestHeaders.Authorization = null;
}

private async Task<TResponse?> Get<TResponse>(string path, CancellationToken cancellation = default)
{
return await Send<EmptyRequestModel, TResponse>(HttpMethod.Get, path, null, cancellation);
}

private async Task<TResponse?> Post<TRequest, TResponse>(string path, TRequest payload, CancellationToken cancellation = default)
{
return await Send<TRequest, TResponse>(HttpMethod.Post, path, payload, cancellation);
}

private async Task<TResponse?> Send<TRequest, TResponse>(HttpMethod method, string path, TRequest? payload, CancellationToken cancellation, bool isRetry = false)
{
if (string.IsNullOrEmpty(Account?.BaseUri.ToString())) throw new BTCPayServerClientException(422, "Account or Server URL missing");

var req = new HttpRequestMessage
{
RequestUri = new Uri($"{Account.BaseUri}btcpayapp/{path}"),
Method = method,
Content = payload == null ? null : JsonContent.Create(payload)
};
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Headers.Add("User-Agent", "BTCPayServerAppApiClient");

if (!_unauthenticatedPaths.Contains(path) && !string.IsNullOrEmpty(Account.AccessToken))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Account.AccessToken);
}

var res = await _httpClient.SendAsync(req, cancellation);
if (!res.IsSuccessStatusCode)
{
// try refresh and recurse if the token is expired
if (res.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(Account.RefreshToken) && !isRetry)
{
var (refresh, _) = await Refresh(Account, cancellation);
if (refresh != null) return await Send<TRequest, TResponse>(method, path, payload, cancellation);
}
// otherwise handle the error response
var problem = await res.Content.ReadFromJsonAsync<ProblemDetails>(cancellationToken: cancellation);
var statusCode = problem?.Status ?? (int)res.StatusCode;
var message = problem?.Detail ?? res.ReasonPhrase;
throw new BTCPayServerClientException(statusCode, message ?? "Request failed");
}

if (typeof(TResponse) == typeof(EmptyResponseModel))
{
return (TResponse)(object)new EmptyResponseModel();
}
return await res.Content.ReadFromJsonAsync<TResponse>(cancellationToken: cancellation);
}

private class EmptyRequestModel;
private class EmptyResponseModel;

private class BTCPayServerClientException(int statusCode, string message) : Exception
{
public int StatusCode { get; init; } = statusCode;
public override string Message => message;
}

private async Task<AccessTokenResult?> HandleAccessTokenResponse(AccessTokenResponse response, DateTimeOffset expiryOffset)
{
var expiry = expiryOffset + TimeSpan.FromSeconds(response.ExpiresIn);
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(response.TokenType, response.AccessToken);
Account!.SetAccess(response.AccessToken, response.RefreshToken, expiry);
await secureConfigProvider.Set("account", Account);
return new AccessTokenResult(response.AccessToken, response.RefreshToken, expiry);
}
}
4 changes: 1 addition & 3 deletions BTCPayApp.Core/Contracts/ISecureConfigProvider.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
namespace BTCPayApp.Core.Contracts;

public interface ISecureConfigProvider : IConfigProvider
{
}
public interface ISecureConfigProvider : IConfigProvider;
8 changes: 8 additions & 0 deletions BTCPayApp.Core/Models/AccessTokenResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace BTCPayApp.Core.Models;

public class AccessTokenResult(string accessToken, string refreshToken, DateTimeOffset expiry)
{
public string AccessToken { get; init; } = accessToken;
public string RefreshToken { get; init; } = refreshToken;
public DateTimeOffset Expiry { get; init; } = expiry;
}
18 changes: 18 additions & 0 deletions BTCPayApp.Core/Models/AppUserInfoResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace BTCPayApp.Core.Models;

public class AppUserInfoResult
{
public string UserId { get; set; }
public string Email { get; set; }
public IEnumerable<string> Roles { get; set; }
public IEnumerable<AppUserStoreInfo> Stores { get; set; }
}

public class AppUserStoreInfo
{
public string Id { get; set; }
public string Name { get; set; }
public string RoleId { get; set; }
public bool Archived { get; set; }
public IEnumerable<string> Permissions { get; set; }
}
12 changes: 5 additions & 7 deletions BTCPayApp.Core/StartupExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@ public static IServiceCollection ConfigureBTCPayAppCore(this IServiceCollection
serviceCollection.AddSingleton<IBTCPayAppServerClient,BTCPayAppServerClient>();
serviceCollection.AddSingleton<BTCPayAppConfigManager>();
serviceCollection.AddSingleton<LightningNodeManager>();
serviceCollection.AddSingleton<IHostedService>(provider =>
provider.GetRequiredService<BTCPayAppConfigManager>());
serviceCollection.AddSingleton<IHostedService>(provider =>
provider.GetRequiredService<BTCPayConnection>());
serviceCollection.AddSingleton<IHostedService>(provider =>
provider.GetRequiredService<LightningNodeManager>());
serviceCollection.AddSingleton<IHostedService>(provider => provider.GetRequiredService<BTCPayAppConfigManager>());
serviceCollection.AddSingleton<IHostedService>(provider => provider.GetRequiredService<BTCPayConnection>());
serviceCollection.AddSingleton<IHostedService>(provider => provider.GetRequiredService<LightningNodeManager>());

return serviceCollection;
}
}
}
3 changes: 2 additions & 1 deletion BTCPayApp.Desktop/StartupExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ public DesktopConfigProvider(IDataDirectoryProvider directoryProvider)
return default;
}
var raw = await File.ReadAllTextAsync(dir);
return JsonSerializer.Deserialize<T>(await ReadFromRaw(raw));
var json = await ReadFromRaw(raw);
return JsonSerializer.Deserialize<T>(json);
}

protected virtual Task<string> ReadFromRaw(string str) => Task.FromResult(str);
Expand Down
1 change: 1 addition & 0 deletions BTCPayApp.Maui/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<script src="_content/BTCPayApp.UI/js/theme.js"></script>
<link href="_content/BTCPayApp.UI/favicon.png" rel="icon" type="image/png"/>
<link href="_content/BTCPayApp.UI/bootstrap/bootstrap.css" rel="stylesheet"/>
<link href="_content/BTCPayApp.UI/css/bootstrap-adaptations.css" rel="stylesheet"/>
<link href="_content/BTCPayApp.UI/css/fonts.css" rel="stylesheet"/>
<link href="_content/BTCPayApp.UI/css/theme.css" rel="stylesheet"/>
<link href="_content/BTCPayApp.UI/css/global.css" rel="stylesheet"/>
Expand Down
1 change: 1 addition & 0 deletions BTCPayApp.Photino/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<script src="_content/BTCPayApp.UI/js/theme.js"></script>
<link href="_content/BTCPayApp.UI/favicon.png" rel="icon" type="image/png"/>
<link href="_content/BTCPayApp.UI/bootstrap/bootstrap.css" rel="stylesheet"/>
<link href="_content/BTCPayApp.UI/css/bootstrap-adaptations.css" rel="stylesheet"/>
<link href="_content/BTCPayApp.UI/css/fonts.css" rel="stylesheet"/>
<link href="_content/BTCPayApp.UI/css/theme.css" rel="stylesheet"/>
<link href="_content/BTCPayApp.UI/css/global.css" rel="stylesheet"/>
Expand Down
1 change: 1 addition & 0 deletions BTCPayApp.Server/Pages/_Host.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<script src="_content/BTCPayApp.UI/js/theme.js"></script>
<link href="_content/BTCPayApp.UI/favicon.png" rel="icon" type="image/png" asp-append-version="true"/>
<link href="_content/BTCPayApp.UI/bootstrap/bootstrap.css" rel="stylesheet" asp-append-version="true"/>
<link href="_content/BTCPayApp.UI/css/bootstrap-adaptations.css" rel="stylesheet" asp-append-version="true"/>
<link href="_content/BTCPayApp.UI/css/fonts.css" rel="stylesheet" asp-append-version="true"/>
<link href="_content/BTCPayApp.UI/css/theme.css" rel="stylesheet" asp-append-version="true"/>
<link href="_content/BTCPayApp.UI/css/global.css" rel="stylesheet" asp-append-version="true"/>
Expand Down
Loading

0 comments on commit 90089b6

Please sign in to comment.