app/BTCPayApp.Core/Auth/AuthStateProvider.cs
Dennis Reimann a239f92aed
Handle user data changes
Closes #21.
2024-07-30 23:58:54 +02:00

452 lines
17 KiB
C#

using System.Security.Claims;
using BTCPayApp.CommonServer.Models;
using BTCPayApp.Core.AspNetRip;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Helpers;
using BTCPayServer.Abstractions.Constants;
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(
IConfigProvider config,
IAuthorizationService authService,
IOptionsMonitor<IdentityOptions> identityOptions)
: AuthenticationStateProvider, IAccountManager, IHostedService
{
private const string AccountKeyPrefix = "Account";
private const string CurrentAccountKey = "CurrentAccount";
private bool _isInitialized;
private BTCPayAccount? _account;
private AppUserInfo? _userInfo;
private CancellationTokenSource? _pingCts;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly ClaimsPrincipal _unauthenticated = new(new ClaimsIdentity());
public BTCPayAccount? GetAccount() => _account;
public AppUserInfo? GetUserInfo() => _userInfo;
public AsyncEventHandler<BTCPayAccount?>? OnBeforeAccountChange { get; set; }
public AsyncEventHandler<BTCPayAccount?>? OnAfterAccountChange { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnBeforeStoreChange { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnAfterStoreChange { get; set; }
public AsyncEventHandler<BTCPayAccount?>? OnAccountInfoChange { get; set; }
public AsyncEventHandler<AppUserInfo?>? OnUserInfoChange { get; set; }
public async Task StartAsync(CancellationToken cancellationToken)
{
_pingCts = new CancellationTokenSource();
_ = PingOccasionally(_pingCts.Token);
}
private async Task PingOccasionally(CancellationToken pingCtsToken)
{
while (pingCtsToken.IsCancellationRequested is false)
{
await GetAuthenticationStateAsync();
await Task.Delay(TimeSpan.FromSeconds(5), pingCtsToken);
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_pingCts?.Cancel();
return Task.CompletedTask;
}
public BTCPayAppClient GetClient(string? baseUri = null)
{
if (string.IsNullOrEmpty(baseUri) && string.IsNullOrEmpty(_account?.BaseUri))
throw new ArgumentException("No base URI present or provided.", nameof(baseUri));
var client = new BTCPayAppClient(baseUri ?? _account!.BaseUri);
if (string.IsNullOrEmpty(baseUri) && !string.IsNullOrEmpty(_account?.AccessToken) && !string.IsNullOrEmpty(_account.RefreshToken))
client.SetAccess(_account.AccessToken, _account.RefreshToken, _account.AccessExpiry.GetValueOrDefault());
client.AccessRefreshed += OnAccessRefresh;
return client;
}
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 GetCurrentAccount();
_isInitialized = true;
}
var oldUserInfo = _userInfo;
if (_userInfo == null && _account?.HasTokens is true)
{
var cts = new CancellationTokenSource(5000);
await FetchUserInfo(cts.Token);
}
if (_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))));
user = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthenticationSchemes.GreenfieldBearer));
}
var res = new AuthenticationState(user);
if (AppUserInfo.Equals(oldUserInfo, _userInfo)) return res;
if (_userInfo != null)
{
// update account user info
_account!.SetInfo(_userInfo.Email!, _userInfo.Name, _userInfo.ImageUrl);
OnAccountInfoChange?.Invoke(this, _account);
await UpdateAccount(_account);
}
NotifyAuthenticationStateChanged(Task.FromResult(res));
return res;
}
catch
{
return new AuthenticationState(user);
}
finally
{
_semaphore.Release();
}
}
public async Task<bool> CheckAuthenticated(bool refreshUser = false)
{
if (refreshUser) _userInfo = null;
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 Logout()
{
_userInfo = null;
_account!.ClearAccess();
OnUserInfoChange?.Invoke(this, _userInfo);
await UpdateAccount(_account);
await SetCurrentAccount(null);
}
public async Task<FormResult> SetCurrentStoreId(string storeId)
{
var store = GetUserStore(storeId);
if (store == null) return new FormResult(false, $"Store with ID '{storeId}' does not exist or belong to the user.");
OnBeforeStoreChange?.Invoke(this, GetCurrentStore());
// create associated POS app if there is none
if (string.IsNullOrEmpty(store.PosAppId))
{
try
{
var posConfig = new PointOfSaleAppRequest { AppName = store.Name, DefaultView = PosViewType.Light };
await GetClient().CreatePointOfSaleApp(store.Id, posConfig);
await FetchUserInfo();
}
catch (Exception e)
{
return new FormResult(false, e.Message);
}
}
_account!.CurrentStoreId = storeId;
await UpdateAccount(_account);
OnAfterStoreChange?.Invoke(this, store);
return new FormResult(true);
}
public async Task UnsetCurrentStore()
{
_account!.CurrentStoreId = null;
await UpdateAccount(_account);
}
public AppUserStoreInfo? GetUserStore(string storeId)
{
return _userInfo?.Stores?.FirstOrDefault(store => store.Id == storeId);
}
public AppUserStoreInfo? GetCurrentStore()
{
var storeId = _account?.CurrentStoreId;
return string.IsNullOrEmpty(storeId) ? null : GetUserStore(storeId);
}
public async Task<FormResult<AcceptInviteResult>> AcceptInvite(string inviteUrl, CancellationToken? cancellation = default)
{
var urlParts = inviteUrl.Split("/invite/");
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 = await GetAccount(serverUrl, response.Email);
await SetCurrentAccount(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 e)
{
return new FormResult<AcceptInviteResult>(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 expiryOffset = DateTimeOffset.Now;
var response = await GetClient(serverUrl).Login(payload, cancellation.GetValueOrDefault());
var account = await GetAccount(serverUrl, email);
account.SetAccess(response.AccessToken, response.RefreshToken, response.ExpiresIn, expiryOffset);
await SetCurrentAccount(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 expiryOffset = DateTimeOffset.Now;
var client = GetClient(serverUrl);
var response = await client.Login(code, cancellation.GetValueOrDefault());
var account = await GetAccount(serverUrl, email);
account.SetAccess(response.AccessToken, response.RefreshToken, response.ExpiresIn, expiryOffset);
await SetCurrentAccount(account);
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 SignupRequest
{
Email = email,
Password = password
};
try
{
var expiryOffset = DateTimeOffset.Now;
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<AccessTokenResponse>();
account.SetAccess(access.AccessToken, access.RefreshToken, access.ExpiresIn, expiryOffset);
}
else
{
var signup = response.ToObject<SignupResult>();
if (signup.RequiresConfirmedEmail)
message += " Please confirm your email.";
if (signup.RequiresUserApproval)
message += " The new account requires approval by an admin before you can log in.";
}
await SetCurrentAccount(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);
await GetClient(serverUrl).ResetPassword(payload, cancellation.GetValueOrDefault());
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<ApplicationUserData>> ChangeAccountInfo(string email, string? name, string? imageUrl, CancellationToken? cancellation = default)
{
var payload = new UpdateApplicationUserRequest
{
Email = email,
Name = name,
ImageUrl = imageUrl
};
try
{
var userData = await GetClient().UpdateCurrentUser(payload, cancellation.GetValueOrDefault());
_account!.SetInfo(userData.Email!, userData.Name, userData.ImageUrl);
OnAccountInfoChange?.Invoke(this, _account);
_userInfo!.SetInfo(userData.Email!, userData.Name, userData.ImageUrl);
OnUserInfoChange?.Invoke(this, _userInfo);
return new FormResult<ApplicationUserData>(true, "Your account info has been changed.", userData);
}
catch (Exception e)
{
return new FormResult<ApplicationUserData>(false, e.Message, null);
}
}
public async Task<FormResult> RefreshAccess(CancellationToken? cancellation = default)
{
try
{
await GetClient().RefreshAccess(_account!.RefreshToken, cancellation);
return new FormResult(true);
}
catch (Exception e)
{
return new FormResult(false, e.Message);
}
}
private async void OnAccessRefresh(object? sender, AccessTokenResult access)
{
if (_account == null) return;
_account.SetAccess(access.AccessToken, access.RefreshToken, access.Expiry);
await UpdateAccount(_account);
}
private static string GetKey(string accountId) => $"{AccountKeyPrefix}:{accountId}";
public async Task<IEnumerable<BTCPayAccount>> GetAccounts(string? hostFilter = null)
{
var prefix = $"{AccountKeyPrefix}:" + (hostFilter == null ? "" : $"{hostFilter}:");
var keys = (await config.List(prefix)).ToArray();
var accounts = new List<BTCPayAccount>();
foreach (var key in keys)
{
var account = await config.Get<BTCPayAccount>(key);
accounts.Add(account!);
}
return accounts;
}
public async Task UpdateAccount(BTCPayAccount account)
{
await config.Set(GetKey(account.Id), account, false);
}
public async Task RemoveAccount(BTCPayAccount account)
{
await config.Set<BTCPayAccount>(GetKey(account.Id), null, false);
}
private async Task<BTCPayAccount> GetAccount(string serverUrl, string email)
{
var accountId = BTCPayAccount.GetId(serverUrl, email);
var account = await config.Get<BTCPayAccount>(GetKey(accountId));
return account ?? new BTCPayAccount(serverUrl, email);
}
private async Task<BTCPayAccount?> GetCurrentAccount()
{
var accountId = await config.Get<string>(CurrentAccountKey);
if (string.IsNullOrEmpty(accountId)) return null;
return await config.Get<BTCPayAccount>(GetKey(accountId));
}
private async Task SetCurrentAccount(BTCPayAccount? account)
{
OnBeforeAccountChange?.Invoke(this, _account);
if (account != null) await UpdateAccount(account);
await config.Set(CurrentAccountKey, account?.Id, false);
_account = account;
_userInfo = null;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
OnAfterAccountChange?.Invoke(this, _account);
}
private async Task FetchUserInfo(CancellationToken cancellationToken = default)
{
_userInfo = await GetClient().GetUserInfo(cancellationToken);
OnUserInfoChange?.Invoke(this, _userInfo);
}
}