476 lines
18 KiB
C#
476 lines
18 KiB
C#
using System.Security.Claims;
|
|
using BTCPayApp.Core.Contracts;
|
|
using BTCPayApp.Core.Helpers;
|
|
using BTCPayApp.Core.Models;
|
|
using BTCPayServer.Client.Models;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace BTCPayApp.Core.Auth;
|
|
|
|
public class AuthStateProvider(
|
|
IHttpClientFactory clientFactory,
|
|
IAuthorizationService authService,
|
|
ISecureConfigProvider secureProvider,
|
|
ConfigProvider configProvider,
|
|
IOptionsMonitor<IdentityOptions> identityOptions)
|
|
: AuthenticationStateProvider, IAccountManager, IHostedService
|
|
{
|
|
private bool _isInitialized;
|
|
private bool _refreshUserInfo;
|
|
private string? _currentStoreId;
|
|
private CancellationTokenSource? _pingCts;
|
|
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
|
private readonly ClaimsPrincipal _unauthenticated = new(new ClaimsIdentity());
|
|
|
|
public BTCPayAccount? Account { get; private set; }
|
|
public AppUserInfo? UserInfo { get; private set; }
|
|
public AppUserStoreInfo? CurrentStore => string.IsNullOrEmpty(_currentStoreId) ? null : GetUserStore(_currentStoreId);
|
|
public AsyncEventHandler<AppUserStoreInfo?>? OnStoreChanged { get; set; }
|
|
public AsyncEventHandler<AppUserInfo?>? OnUserInfoChanged { get; set; }
|
|
public AsyncEventHandler<string>? OnEncryptionKeyChanged { get; set; }
|
|
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
_pingCts = new CancellationTokenSource();
|
|
_ = PingOccasionally(_pingCts.Token);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
_pingCts?.Cancel();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task PingOccasionally(CancellationToken pingCtsToken)
|
|
{
|
|
while (pingCtsToken.IsCancellationRequested is false)
|
|
{
|
|
await GetAuthenticationStateAsync();
|
|
await Task.Delay(TimeSpan.FromSeconds(5), pingCtsToken);
|
|
}
|
|
}
|
|
|
|
public BTCPayAppClient GetClient(string? baseUri = null, string? token = null)
|
|
{
|
|
if (string.IsNullOrEmpty(baseUri) && string.IsNullOrEmpty(Account?.BaseUri))
|
|
throw new ArgumentException("No base URI present or provided.", nameof(baseUri));
|
|
token ??= Account?.ModeToken ?? Account?.OwnerToken;
|
|
return new BTCPayAppClient(baseUri ?? Account!.BaseUri, token, clientFactory.CreateClient());
|
|
}
|
|
|
|
public async Task<string?> GetEncryptionKey()
|
|
{
|
|
return await secureProvider.Get<string>("encryptionKey");
|
|
}
|
|
|
|
public async Task SetEncryptionKey(string value)
|
|
{
|
|
await secureProvider.Set("encryptionKey", value);
|
|
OnEncryptionKeyChanged?.Invoke(this, value);
|
|
}
|
|
|
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
{
|
|
// default to unauthenticated
|
|
var user = _unauthenticated;
|
|
try
|
|
{
|
|
await _semaphore.WaitAsync();
|
|
|
|
// initialize with persisted account
|
|
if (!_isInitialized && Account == null)
|
|
{
|
|
Account = await secureProvider.Get<BTCPayAccount>(BTCPayAccount.Key);
|
|
_currentStoreId = (await configProvider.Get<BTCPayAppConfig>(BTCPayAppConfig.Key))?.CurrentStoreId;
|
|
_isInitialized = true;
|
|
}
|
|
|
|
var oldUserInfo = UserInfo;
|
|
var hasOwnerToken = !string.IsNullOrEmpty(Account?.OwnerToken);
|
|
var hasModeToken = !string.IsNullOrEmpty(Account?.ModeToken);
|
|
var needsRefresh = _refreshUserInfo || UserInfo == null;
|
|
if (needsRefresh && hasOwnerToken)
|
|
{
|
|
var cts = new CancellationTokenSource(5000);
|
|
UserInfo = await GetClient().GetUserInfo(cts.Token);
|
|
_refreshUserInfo = false;
|
|
}
|
|
|
|
if (Account != null && UserInfo != null)
|
|
{
|
|
var claims = new List<Claim>
|
|
{
|
|
new(identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, UserInfo.UserId!),
|
|
new(identityOptions.CurrentValue.ClaimsIdentity.UserNameClaimType, UserInfo.Name ?? UserInfo.Email!),
|
|
new(identityOptions.CurrentValue.ClaimsIdentity.EmailClaimType, UserInfo.Email!)
|
|
};
|
|
if (UserInfo.Roles?.Any() is true)
|
|
claims.AddRange(UserInfo.Roles.Select(role =>
|
|
new Claim(identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, role)));
|
|
if (UserInfo.Stores?.Any() is true)
|
|
claims.AddRange(UserInfo.Stores.Select(store =>
|
|
new Claim(store.Id, string.Join(',', store.Permissions ?? []))));
|
|
if (hasOwnerToken && !hasModeToken)
|
|
claims.Add(new Claim(identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, "DeviceOwner"));
|
|
user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Greenfield"));
|
|
}
|
|
|
|
var res = new AuthenticationState(user);
|
|
if (AppUserInfo.Equals(oldUserInfo, UserInfo)) return res;
|
|
|
|
OnUserInfoChanged?.Invoke(this, UserInfo);
|
|
if (Account != null && UserInfo != null)
|
|
await UpdateAccount(Account);
|
|
|
|
NotifyAuthenticationStateChanged(Task.FromResult(res));
|
|
return res;
|
|
}
|
|
catch
|
|
{
|
|
UserInfo = null;
|
|
return new AuthenticationState(user);
|
|
}
|
|
finally
|
|
{
|
|
_semaphore.Release();
|
|
}
|
|
}
|
|
|
|
public async Task<bool> CheckAuthenticated(bool refreshUser = false)
|
|
{
|
|
if (refreshUser) _refreshUserInfo = true;
|
|
await GetAuthenticationStateAsync();
|
|
return UserInfo != null;
|
|
}
|
|
|
|
public async Task<bool> IsAuthorized(string policy, object? resource = null)
|
|
{
|
|
var authState = await GetAuthenticationStateAsync();
|
|
var result = await authService.AuthorizeAsync(authState.User, resource, policy);
|
|
return result.Succeeded;
|
|
}
|
|
|
|
public async Task<FormResult> SetCurrentStoreId(string? storeId)
|
|
{
|
|
if (!string.IsNullOrEmpty(storeId))
|
|
{
|
|
var store = GetUserStore(storeId);
|
|
if (store == null) return new FormResult(false, $"Store with ID '{storeId}' does not exist or belong to the user.");
|
|
|
|
if (store.Id != CurrentStore?.Id)
|
|
await SetCurrentStore(store);
|
|
}
|
|
else
|
|
{
|
|
await SetCurrentStore(null);
|
|
}
|
|
return new FormResult(true);
|
|
}
|
|
|
|
private async Task SetCurrentStore(AppUserStoreInfo? store)
|
|
{
|
|
if (_currentStoreId == store?.Id) return;
|
|
|
|
if (store != null)
|
|
store = await EnsureStorePos(store);
|
|
|
|
_currentStoreId = store?.Id;
|
|
|
|
var appConfig = await configProvider.Get<BTCPayAppConfig>(BTCPayAppConfig.Key) ?? new BTCPayAppConfig();
|
|
appConfig.CurrentStoreId = _currentStoreId;
|
|
await configProvider.Set(BTCPayAppConfig.Key, appConfig, true);
|
|
|
|
OnStoreChanged?.Invoke(this, store);
|
|
}
|
|
|
|
public async Task<AppUserStoreInfo> EnsureStorePos(AppUserStoreInfo store, bool? forceCreate = false)
|
|
{
|
|
if (string.IsNullOrEmpty(store.PosAppId) || forceCreate is true)
|
|
{
|
|
try
|
|
{
|
|
var posConfig = new PointOfSaleAppRequest { AppName = store.Name, DefaultView = PosViewType.Light };
|
|
await GetClient().CreatePointOfSaleApp(store.Id, posConfig);
|
|
await CheckAuthenticated(true);
|
|
store = GetUserStore(store.Id)!;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// ignored
|
|
}
|
|
}
|
|
return store;
|
|
}
|
|
|
|
private AppUserStoreInfo? GetUserStore(string storeId)
|
|
{
|
|
return UserInfo?.Stores?.FirstOrDefault(store => store.Id == storeId);
|
|
}
|
|
|
|
public async Task<FormResult<AcceptInviteResult>> AcceptInvite(string inviteUrl, CancellationToken? cancellation = default)
|
|
{
|
|
var urlParts = inviteUrl.Split(Constants.InviteSeparator);
|
|
var serverUrl = urlParts.First();
|
|
var pathParts = urlParts.Last().Split("/");
|
|
var payload = new AcceptInviteRequest
|
|
{
|
|
UserId = pathParts[0],
|
|
Code = pathParts[1]
|
|
};
|
|
try
|
|
{
|
|
var response = await GetClient(serverUrl).AcceptInvite(payload, cancellation.GetValueOrDefault());
|
|
var account = new BTCPayAccount(serverUrl, response.Email!);
|
|
await SetAccount(account);
|
|
var message = "Invitation accepted.";
|
|
if (response.EmailHasBeenConfirmed is true)
|
|
message += " Your email has been confirmed.";
|
|
if (response.RequiresUserApproval is true)
|
|
message += " The new account requires approval by an admin before you can log in.";
|
|
message += string.IsNullOrEmpty(response.PasswordSetCode)
|
|
? " Your password has been set by the user who invited you."
|
|
: " Please set your password.";
|
|
return new FormResult<AcceptInviteResult>(true, message, response);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return new FormResult<AcceptInviteResult>(false, "Invalid invitation.", null);
|
|
}
|
|
}
|
|
|
|
public async Task<FormResult<LoginInfoResult>> LoginInfo(string serverUrl, string email, CancellationToken? cancellation = default)
|
|
{
|
|
try
|
|
{
|
|
var response = await GetClient(serverUrl).LoginInfo(email, cancellation.GetValueOrDefault());
|
|
return new FormResult<LoginInfoResult>(true, string.Empty, response);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new FormResult<LoginInfoResult>(false, e.Message, null);
|
|
}
|
|
}
|
|
|
|
public async Task<FormResult> 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 response = await GetClient(serverUrl).Login(payload, cancellation.GetValueOrDefault());
|
|
if (string.IsNullOrEmpty(response.AccessToken)) throw new Exception("Did not obtain valid API token.");
|
|
var account = new BTCPayAccount(serverUrl, email, response.AccessToken);
|
|
await SetAccount(account);
|
|
return new FormResult(true);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new FormResult(false, e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<FormResult> LoginWithCode(string serverUrl, string? email, string code, CancellationToken? cancellation = default)
|
|
{
|
|
try
|
|
{
|
|
var client = GetClient(serverUrl);
|
|
var response = await client.Login(code, cancellation.GetValueOrDefault());
|
|
if (string.IsNullOrEmpty(response.AccessToken)) throw new Exception("Did not obtain valid API token.");
|
|
if (string.IsNullOrEmpty(email))
|
|
{
|
|
var clientWithToken = GetClient(serverUrl, response.AccessToken);
|
|
var userInfo = await clientWithToken.GetUserInfo();
|
|
email = userInfo?.Email!;
|
|
}
|
|
var account = new BTCPayAccount(serverUrl, email, response.AccessToken);
|
|
await SetAccount(account);
|
|
return new FormResult(true);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new FormResult(false, e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<FormResult> AddAccountWithEncyptionKey(string serverUrl, string email, string key)
|
|
{
|
|
try
|
|
{
|
|
var account = new BTCPayAccount(serverUrl, email);
|
|
await SetAccount(account);
|
|
await SetEncryptionKey(key);
|
|
return new FormResult(true);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new FormResult(false, e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<FormResult> Register(string serverUrl, string email, string password, CancellationToken? cancellation = default)
|
|
{
|
|
var payload = new CreateApplicationUserRequest
|
|
{
|
|
Email = email,
|
|
Password = password
|
|
};
|
|
try
|
|
{
|
|
var response = await GetClient(serverUrl).RegisterUser(payload, cancellation.GetValueOrDefault());
|
|
var account = new BTCPayAccount(serverUrl, email);
|
|
var message = "Account created.";
|
|
if (response.ContainsKey("accessToken"))
|
|
{
|
|
var access = response.ToObject<AuthenticationResponse>();
|
|
if (string.IsNullOrEmpty(access?.AccessToken)) throw new Exception("Did not obtain valid API token.");
|
|
account.OwnerToken = access.AccessToken;
|
|
}
|
|
else
|
|
{
|
|
var signup = response.ToObject<ApplicationUserData>();
|
|
if (signup?.RequiresEmailConfirmation is true)
|
|
message += " Please confirm your email.";
|
|
if (signup?.RequiresApproval is true)
|
|
message += " The new account requires approval by an admin before you can log in.";
|
|
}
|
|
await SetAccount(account);
|
|
return new FormResult(true, message);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new FormResult(false, e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<FormResult> 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 isForgotStep = string.IsNullOrEmpty(payload.ResetCode) && string.IsNullOrEmpty(payload.NewPassword);
|
|
var response = await GetClient(serverUrl).ResetPassword(payload, cancellation.GetValueOrDefault());
|
|
if (response?.ContainsKey("accessToken") is true)
|
|
{
|
|
var access = response.ToObject<AuthenticationResponse>();
|
|
var account = new BTCPayAccount(serverUrl, email, access!.AccessToken);
|
|
await SetAccount(account);
|
|
}
|
|
|
|
return new FormResult(true, isForgotStep
|
|
? "You should have received an email with a password reset code."
|
|
: "Your password has been reset.");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new FormResult(false, e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<FormResult<ApplicationUserData>> ChangePassword(string currentPassword, string newPassword, CancellationToken? cancellation = default)
|
|
{
|
|
var payload = new UpdateApplicationUserRequest
|
|
{
|
|
CurrentPassword = currentPassword,
|
|
NewPassword = newPassword
|
|
};
|
|
try
|
|
{
|
|
var response = await GetClient().UpdateCurrentUser(payload, cancellation.GetValueOrDefault());
|
|
return new FormResult<ApplicationUserData>(true, "Your password has been changed.", response);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new FormResult<ApplicationUserData>(false, e.Message, null);
|
|
}
|
|
}
|
|
|
|
public async Task<FormResult> SwitchMode(string storeId, string mode, CancellationToken? cancellation = default)
|
|
{
|
|
if (Account == null || !string.IsNullOrEmpty(Account.ModeToken))
|
|
return new FormResult(false, "Cannot switch mode in current state.");
|
|
|
|
var payload = new SwitchModeRequest
|
|
{
|
|
StoreId = storeId,
|
|
Mode = mode
|
|
};
|
|
try
|
|
{
|
|
var response = await GetClient().SwitchMode(payload, cancellation.GetValueOrDefault());
|
|
if (string.IsNullOrEmpty(response.AccessToken)) throw new Exception("Did not obtain valid API token.");
|
|
|
|
Account.ModeToken = response.AccessToken;
|
|
await SetAccount(Account);
|
|
return new FormResult(true);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new FormResult(false, e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<FormResult> SwitchToOwner(string password, string? otp = null, CancellationToken? cancellation = default)
|
|
{
|
|
if (Account == null || string.IsNullOrEmpty(Account.ModeToken) || string.IsNullOrEmpty(Account.OwnerToken))
|
|
return new FormResult(false, "Cannot switch user in current state.");
|
|
|
|
var payload = new LoginRequest
|
|
{
|
|
Email = Account.Email,
|
|
Password = password,
|
|
TwoFactorCode = otp
|
|
};
|
|
try
|
|
{
|
|
var response = await GetClient().Login(payload, cancellation.GetValueOrDefault());
|
|
if (string.IsNullOrEmpty(response.AccessToken)) throw new Exception("Did not obtain valid API token.");
|
|
Account.ModeToken = null;
|
|
await SetAccount(Account);
|
|
return new FormResult(true);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new FormResult(false, e.Message);
|
|
}
|
|
}
|
|
|
|
public async Task Logout()
|
|
{
|
|
if (Account == null) return;
|
|
Account.OwnerToken = Account.ModeToken = null;
|
|
await SetAccount(Account);
|
|
}
|
|
|
|
private async Task UpdateAccount(BTCPayAccount account)
|
|
{
|
|
await secureProvider.Set(BTCPayAccount.Key, account);
|
|
}
|
|
|
|
private async Task SetAccount(BTCPayAccount account)
|
|
{
|
|
var storeId = CurrentStore?.Id;
|
|
|
|
await UpdateAccount(account);
|
|
Account = account;
|
|
UserInfo = null;
|
|
|
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
|
|
|
if (!string.IsNullOrEmpty(storeId)) await SetCurrentStoreId(storeId);
|
|
}
|
|
}
|