diff --git a/BTCPayApp.Core/BTCPayAccount.cs b/BTCPayApp.Core/BTCPayAccount.cs new file mode 100644 index 00000000..cf0b4ca9 --- /dev/null +++ b/BTCPayApp.Core/BTCPayAccount.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace BTCPayApp.Core; + +public class BTCPayAccount(string baseUri, string email) +{ + public string BaseUri { get; set; } = baseUri; + public string Email { get; set; } = email; + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public DateTimeOffset? AccessExpiry { get; set; } + + [JsonConstructor] + public BTCPayAccount() : this(string.Empty, string.Empty) {} + + public void SetAccess(string accessToken, string refreshToken, long expiresInSeconds, DateTimeOffset? expiryOffset = null) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + AccessExpiry = (expiryOffset ?? DateTimeOffset.Now) + TimeSpan.FromSeconds(expiresInSeconds); + } + + public void ClearAccess() + { + AccessToken = RefreshToken = null; + AccessExpiry = null; + } +} + diff --git a/BTCPayApp.Core/BTCPayAppClient.cs b/BTCPayApp.Core/BTCPayAppClient.cs new file mode 100644 index 00000000..8cf9033e --- /dev/null +++ b/BTCPayApp.Core/BTCPayAppClient.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using BTCPayApp.CommonServer; +using Microsoft.AspNetCore.Authentication.BearerToken; +using Microsoft.AspNetCore.Identity.Data; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayApp.Core; + +public class BTCPayAppClient(IHttpClientFactory clientFactory) +{ + private readonly HttpClient _httpClient = clientFactory.CreateClient(); + private readonly string[] _unauthenticatedPaths = ["login", "forgot-password", "reset-password"]; + private DateTimeOffset? AccessExpiry { get; set; } // TODO: Incorporate in refresh check + private string? AccessToken { get; set; } + private string? RefreshToken { get; set; } + + public async Task Get(string baseUrl, string path, CancellationToken cancellation = default, bool isRetry = false) + { + return await Send(HttpMethod.Get, baseUrl, path, null, cancellation, isRetry); + } + + public async Task Post(string baseUrl, string path, TRequest payload, CancellationToken cancellation = default, bool isRetry = false) + { + await Send(HttpMethod.Post, baseUrl, path, payload, cancellation, isRetry); + } + + public async Task Post(string baseUrl, string path, TRequest payload, CancellationToken cancellation = default, bool isRetry = false) + { + return await Send(HttpMethod.Post, baseUrl, path, payload, cancellation, isRetry); + } + + private async Task Send(HttpMethod method, string baseUrl, string path, TRequest? payload, CancellationToken cancellation, bool isRetry = false) + { + var req = new HttpRequestMessage + { + RequestUri = new Uri(WithTrailingSlash(baseUrl) + $"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)) + { + if (string.IsNullOrEmpty(AccessToken)) + throw new BTCPayAppClientException(401, "Authentication required"); + + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken); + } + + var res = await _httpClient.SendAsync(req, cancellation); + if (!res.IsSuccessStatusCode) + { + if (res.StatusCode == HttpStatusCode.Unauthorized) + { + // try refresh and recurse if the token is expired + if (!string.IsNullOrEmpty(RefreshToken) && !isRetry) + { + var (refresh, _) = await Refresh(baseUrl, RefreshToken, cancellation); + if (refresh != null) return await Send(method, baseUrl, path, payload, cancellation); + } + + ClearAccess(); + } + // otherwise handle the error response + var problem = await res.Content.ReadFromJsonAsync(cancellationToken: cancellation); + var statusCode = problem?.Status ?? (int)res.StatusCode; + var message = problem?.Detail ?? res.ReasonPhrase; + throw new BTCPayAppClientException(statusCode, message ?? "Request failed"); + } + + if (typeof(TResponse) == typeof(EmptyResponseModel)) + { + return (TResponse)(object)new EmptyResponseModel(); + } + + var response = await res.Content.ReadFromJsonAsync(cancellationToken: cancellation); + return response != null ? response : (TResponse)(object)new EmptyResponseModel(); + } + + private AccessTokenResult HandleAccessTokenResponse(AccessTokenResponse response, DateTimeOffset expiryOffset) + { + var expiry = expiryOffset + TimeSpan.FromSeconds(response.ExpiresIn); + SetAccess(response.AccessToken, response.RefreshToken, expiry); + return new AccessTokenResult(response.AccessToken, response.RefreshToken, expiry); + } + + private async Task<(AccessTokenResult? success, string? errorCode)> Refresh(string serverUrl, string refreshToken, CancellationToken? cancellation = default) + { + var payload = new RefreshRequest { RefreshToken = refreshToken }; + var now = DateTimeOffset.Now; + try + { + var response = await Post(serverUrl, "refresh", payload, cancellation.GetValueOrDefault(), true); + var res = HandleAccessTokenResponse(response, now); + return (res, null); + } + catch (BTCPayAppClientException e) + { + return (null, e.Message); + } + } + + public void ClearAccess() + { + AccessToken = RefreshToken = null; + AccessExpiry = null; + } + + public void SetAccess(string accessToken, string refreshToken, DateTimeOffset expiry) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + AccessExpiry = expiry; + } + + private static string WithTrailingSlash(string str) => str.EndsWith('/') ? str : str + "/"; + + private class EmptyRequestModel; + private class EmptyResponseModel; +} diff --git a/BTCPayApp.Core/BTCPayAppClientException.cs b/BTCPayApp.Core/BTCPayAppClientException.cs new file mode 100644 index 00000000..ede6ca4f --- /dev/null +++ b/BTCPayApp.Core/BTCPayAppClientException.cs @@ -0,0 +1,7 @@ +namespace BTCPayApp.Core; + +public class BTCPayAppClientException(int statusCode, string message) : Exception +{ + public int StatusCode { get; init; } = statusCode; + public override string Message => message; +} diff --git a/BTCPayApp.Core/BTCPayServerAccount.cs b/BTCPayApp.Core/BTCPayServerAccount.cs deleted file mode 100644 index af422c92..00000000 --- a/BTCPayApp.Core/BTCPayServerAccount.cs +++ /dev/null @@ -1,33 +0,0 @@ -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 + "/"; -} - diff --git a/BTCPayApp.Core/BTCPayServerAppApiClient.cs b/BTCPayApp.Core/BTCPayServerAppApiClient.cs deleted file mode 100644 index d37f057b..00000000 --- a/BTCPayApp.Core/BTCPayServerAppApiClient.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using BTCPayApp.CommonServer; -using BTCPayApp.Core.Contracts; -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("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("refresh", payload, cancellation.GetValueOrDefault()); - var res = await HandleAccessTokenResponse(response!, now); - return (res, null); - } - catch (BTCPayServerClientException e) - { - return (null, e.Message); - } - } - - public async Task GetUserInfo(BTCPayServerAccount account, CancellationToken? cancellation = default) - { - Account = account; - return await Get("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(path, payload, cancellation.GetValueOrDefault()); - return (true, null); - } - catch (BTCPayServerClientException e) - { - return (false, e.Message); - } - } - - public void Logout() - { - _httpClient.DefaultRequestHeaders.Authorization = null; - } - - private async Task Get(string path, CancellationToken cancellation = default) - { - return await Send(HttpMethod.Get, path, null, cancellation); - } - - private async Task Post(string path, TRequest payload, CancellationToken cancellation = default) - { - return await Send(HttpMethod.Post, path, payload, cancellation); - } - - private async Task Send(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(method, path, payload, cancellation); - } - // otherwise handle the error response - var problem = await res.Content.ReadFromJsonAsync(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(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 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); - } -} diff --git a/BTCPayApp.UI/App.razor b/BTCPayApp.UI/App.razor index 778eb3b2..2588c7b8 100644 --- a/BTCPayApp.UI/App.razor +++ b/BTCPayApp.UI/App.razor @@ -9,22 +9,19 @@ BTCPay Server - - - - - - - - - - - Not found - - - - - + + + + @RouterWithLayout(typeof(MainLayout)) + + + @RouterWithLayout(typeof(BaseLayout)) + + + @RouterWithLayout(typeof(SimpleLayout)) + + + @code { [CascadingParameter] @@ -35,7 +32,6 @@ protected override async Task OnInitializedAsync() { - // UI LoadingStateSelection.Select(state => state.Loading); var state = await ConfigProvider.Get(StateMiddleware.UiStateConfigKey); if (state != null) @@ -46,4 +42,25 @@ } } } + + RenderFragment RouterWithLayout(Type layoutType) => __builder => + { + + + + + + + + + + + + Not found + + + + + + }; } diff --git a/BTCPayApp.UI/Auth/AuthStateProvider.cs b/BTCPayApp.UI/Auth/AuthStateProvider.cs new file mode 100644 index 00000000..a6a8f147 --- /dev/null +++ b/BTCPayApp.UI/Auth/AuthStateProvider.cs @@ -0,0 +1,138 @@ +using System.Security.Claims; +using BTCPayApp.CommonServer; +using BTCPayApp.Core; +using BTCPayApp.Core.Contracts; +using BTCPayApp.UI.Models; +using Microsoft.AspNetCore.Authentication.BearerToken; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.Data; +using Microsoft.Extensions.Options; + +namespace BTCPayApp.UI.Auth; + +public class AuthStateProvider( + BTCPayAppClient client, + ISecureConfigProvider config, + IOptionsMonitor identityOptions +) : AuthenticationStateProvider, IAccountManager +{ + private bool _isInitialized; + private BTCPayAccount? _account; + private AppUserInfo? _userInfo; + private readonly ClaimsPrincipal _unauthenticated = new(new ClaimsIdentity()); + + public BTCPayAccount? GetAccount() => _account; + public AppUserInfo? GetUserInfo() => _userInfo; + + public override async Task GetAuthenticationStateAsync() + { + // default to unauthenticated + var user = _unauthenticated; + + // initialize with persisted account + if (!_isInitialized && _account == null) + { + _account = await config.Get("account"); + if (!string.IsNullOrEmpty(_account?.AccessToken) && !string.IsNullOrEmpty(_account.RefreshToken)) + client.SetAccess(_account.AccessToken, _account.RefreshToken, _account.AccessExpiry.GetValueOrDefault()); + else + client.ClearAccess(); + _isInitialized = true; + } + + if (_account != null && _userInfo == null) + { + try + { + _userInfo = await client.Get(_account!.BaseUri, "info"); + } + catch { /* ignored */ } + } + + if (_userInfo != null) + { + var claims = new List + { + new (identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, _userInfo.UserId), + new (identityOptions.CurrentValue.ClaimsIdentity.UserNameClaimType, _userInfo.Email), + new (identityOptions.CurrentValue.ClaimsIdentity.EmailClaimType, _userInfo.Email) + }; + claims.AddRange(_userInfo.Roles.Select(role => + new Claim(identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, role))); + user = new ClaimsPrincipal(new ClaimsIdentity(claims, nameof(AuthStateProvider))); + } + + return new AuthenticationState(user); + } + + public async Task CheckAuthenticated() + { + await GetAuthenticationStateAsync(); + return _userInfo != null; + } + + public async Task Logout() + { + _userInfo = null; + _account!.ClearAccess(); + await config.Set("account", _account); + client.ClearAccess(); + + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + private async Task SetAccount(BTCPayAccount account) + { + _account = account; + await config.Set("account", _account); + client.SetAccess(_account.AccessToken!, _account.RefreshToken!, _account.AccessExpiry.GetValueOrDefault()); + + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + public async Task Login(string serverUrl, string email, string password, string? otp = null, CancellationToken? cancellation = default) + { + var payload = new LoginRequest + { + Email = email, + Password = password, + TwoFactorCode = otp + }; + try + { + var expiryOffset = DateTimeOffset.Now; + var response = await client.Post(serverUrl, "login", payload, cancellation.GetValueOrDefault()); + var account = new BTCPayAccount(serverUrl, email); + account.SetAccess(response.AccessToken, response.RefreshToken, response.ExpiresIn, expiryOffset); + await SetAccount(account); + return new FormResult(true); + } + catch (BTCPayAppClientException e) + { + return new FormResult(e.Message); + } + } + + public async Task ResetPassword(string serverUrl, string email, string? resetCode = null, string? newPassword = null, CancellationToken? cancellation = default) + { + var payload = new ResetPasswordRequest + { + Email = 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 client.Post(serverUrl, path, payload, cancellation.GetValueOrDefault()); + return new FormResult(true); + } + catch (BTCPayAppClientException e) + { + return new FormResult(e.Message); + } + } +} diff --git a/BTCPayApp.UI/Auth/IAccountManager.cs b/BTCPayApp.UI/Auth/IAccountManager.cs new file mode 100644 index 00000000..6bb41dbc --- /dev/null +++ b/BTCPayApp.UI/Auth/IAccountManager.cs @@ -0,0 +1,15 @@ +using BTCPayApp.CommonServer; +using BTCPayApp.Core; +using BTCPayApp.UI.Models; + +namespace BTCPayApp.UI.Auth; + +public interface IAccountManager +{ + public BTCPayAccount? GetAccount(); + public AppUserInfo? GetUserInfo(); + public Task CheckAuthenticated(); + public Task Login(string serverUrl, string email, string password, string? otp, CancellationToken? cancellation = default); + public Task ResetPassword(string serverUrl, string email, string? resetCode, string? newPassword, CancellationToken? cancellation = default); + public Task Logout(); +} diff --git a/BTCPayApp.UI/AuthStateProvider.cs b/BTCPayApp.UI/AuthStateProvider.cs deleted file mode 100644 index cccd1eee..00000000 --- a/BTCPayApp.UI/AuthStateProvider.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Security.Claims; -using BTCPayApp.Core; -using Microsoft.AspNetCore.Components.Authorization; - -namespace BTCPayApp.UI; - -public class AuthStateProvider : AuthenticationStateProvider -{ - public BTCPayServerAccount? Account { get; private set; } - - public override Task GetAuthenticationStateAsync() - { - var identity = new ClaimsIdentity(); - if (Account != null) - { - var claims = new[] - { - new Claim(ClaimTypes.Uri, Account.BaseUri.AbsoluteUri), - new Claim(ClaimTypes.Name, Account.Email), - new Claim(ClaimTypes.Email, Account.Email), - new Claim("AccessToken", Account.AccessToken), - new Claim("RefreshToken", Account.RefreshToken) - }; - identity = new ClaimsIdentity(claims, "Bearer"); - } - return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(identity))); - } - - public bool SetAccount(BTCPayServerAccount? account) - { - if (account == null || string.IsNullOrEmpty(account.AccessToken) || string.IsNullOrEmpty(account.RefreshToken)) - { - Logout(); - return false; - } - - Account = account; - NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); - return true; - } - - public void Logout() - { - Account = null; - NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); - } -} diff --git a/BTCPayApp.UI/Components/RedirectToIndex.razor b/BTCPayApp.UI/Components/RedirectToIndex.razor index ef55bef9..6348a431 100644 --- a/BTCPayApp.UI/Components/RedirectToIndex.razor +++ b/BTCPayApp.UI/Components/RedirectToIndex.razor @@ -7,7 +7,7 @@ { NavigationManager.NavigateTo(Routes.Index); } - catch (Exception) + catch { // ignored, see https://github.com/dotnet/aspnetcore/issues/53996 } diff --git a/BTCPayApp.UI/Layout/MainLayout.razor b/BTCPayApp.UI/Layout/MainLayout.razor index e2e9f8ea..c4ea1383 100644 --- a/BTCPayApp.UI/Layout/MainLayout.razor +++ b/BTCPayApp.UI/Layout/MainLayout.razor @@ -19,7 +19,7 @@