diff --git a/src/Artemis.UI/Assets/Images/avatar-placeholder.png b/src/Artemis.UI/Assets/Images/avatar-placeholder.png new file mode 100644 index 000000000..e7bdbefd2 Binary files /dev/null and b/src/Artemis.UI/Assets/Images/avatar-placeholder.png differ diff --git a/src/Artemis.UI/Screens/Settings/Tabs/AboutTabView.axaml b/src/Artemis.UI/Screens/Settings/Tabs/AboutTabView.axaml index 6d21dc30e..5f7a0ebfe 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/AboutTabView.axaml +++ b/src/Artemis.UI/Screens/Settings/Tabs/AboutTabView.axaml @@ -56,8 +56,8 @@ Grid.Column="0" Grid.RowSpan="3" VerticalAlignment="Top" - Height="75" - Width="75" + Height="40" + Width="40" Margin="0 0 15 0" IsVisible="{CompiledBinding RobertProfileImage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" RenderOptions.BitmapInterpolationMode="HighQuality"> diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml new file mode 100644 index 000000000..57a245c81 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sign out + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml.cs new file mode 100644 index 000000000..b690eb8dc --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.CurrentUser; + +public partial class CurrentUserView : ReactiveUserControl +{ + public CurrentUserView() + { + InitializeComponent(); + } + + private void Button_OnClick(object? sender, RoutedEventArgs e) + { + UserMenu.ContextFlyout?.Hide(); + ViewModel?.Logout(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs new file mode 100644 index 000000000..3d0121043 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.WebClient.Workshop.Services; +using Avalonia.Media.Imaging; +using Flurl.Http; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.CurrentUser; + +public class CurrentUserViewModel : ActivatableViewModelBase +{ + private readonly IAuthenticationService _authenticationService; + private ObservableAsPropertyHelper? _isLoggedIn; + + private string? _userId; + private string? _name; + private string? _email; + private Bitmap? _avatar; + + public CurrentUserViewModel(IAuthenticationService authenticationService) + { + _authenticationService = authenticationService; + Login = ReactiveCommand.CreateFromTask(ExecuteLogin); + + this.WhenActivated(d => _isLoggedIn = _authenticationService.WhenAnyValue(s => s.IsLoggedIn).ToProperty(this, vm => vm.IsLoggedIn).DisposeWith(d)); + this.WhenActivated(d => + { + Task.Run(async () => + { + await _authenticationService.AutoLogin(); + await LoadCurrentUser(); + }).DisposeWith(d); + }); + } + + public void Logout() + { + _authenticationService.Logout(); + } + + public string? UserId + { + get => _userId; + set => RaiseAndSetIfChanged(ref _userId, value); + } + + public string? Name + { + get => _name; + set => RaiseAndSetIfChanged(ref _name, value); + } + + public string? Email + { + get => _email; + set => RaiseAndSetIfChanged(ref _email, value); + } + + public Bitmap? Avatar + { + get => _avatar; + set => RaiseAndSetIfChanged(ref _avatar, value); + } + + public ReactiveCommand Login { get; } + public bool IsLoggedIn => _isLoggedIn?.Value ?? false; + + private async Task ExecuteLogin(CancellationToken cancellationToken) + { + await _authenticationService.Login(); + await LoadCurrentUser(); + Console.WriteLine(_authenticationService.Claims); + } + + private async Task LoadCurrentUser() + { + if (!IsLoggedIn) + return; + + UserId = _authenticationService.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; + Name = _authenticationService.Claims.FirstOrDefault(c => c.Type == "name")?.Value; + Email = _authenticationService.Claims.FirstOrDefault(c => c.Type == "email")?.Value; + + if (UserId != null) + await LoadAvatar(UserId); + } + + private async Task LoadAvatar(string userId) + { + try + { + Avatar = new Bitmap(await $"{IAuthenticationService.AUTHORITY}/user/avatar/{userId}".GetStreamAsync()); + } + catch (Exception) + { + // ignored + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml b/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml index 485e308ca..e303305de 100644 --- a/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml +++ b/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml @@ -30,6 +30,7 @@ + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs b/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs index 44fbb4226..06be34781 100644 --- a/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs @@ -4,6 +4,7 @@ using System.Reactive; using System.Reactive.Linq; using System.Threading.Tasks; using Artemis.Core; +using Artemis.UI.Screens.Workshop.CurrentUser; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; using Artemis.WebClient.Workshop; @@ -18,8 +19,9 @@ public class WorkshopViewModel : MainScreenViewModel { private readonly IWorkshopClient _workshopClient; - public WorkshopViewModel(IScreen hostScreen, IWorkshopClient workshopClient) : base(hostScreen, "workshop") + public WorkshopViewModel(IScreen hostScreen, IWorkshopClient workshopClient, CurrentUserViewModel currentUserViewModel) : base(hostScreen, "workshop") { + CurrentUserViewModel = currentUserViewModel; _workshopClient = workshopClient; DisplayName = "Workshop"; @@ -27,7 +29,8 @@ public class WorkshopViewModel : MainScreenViewModel } public ObservableCollection Test { get; set; } = new(); - + public CurrentUserViewModel CurrentUserViewModel { get; set; } + private async Task GetEntries() { diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index 0b6943ddc..ecacce222 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -9,6 +9,7 @@ + @@ -17,4 +18,8 @@ + + + + diff --git a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs index aed1df5a7..1988fa300 100644 --- a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs @@ -1,5 +1,8 @@ +using Artemis.WebClient.Workshop.Repositories; +using Artemis.WebClient.Workshop.Services; using DryIoc; using DryIoc.Microsoft.DependencyInjection; +using IdentityModel.Client; using Microsoft.Extensions.DependencyInjection; namespace Artemis.WebClient.Workshop.DryIoc; @@ -20,7 +23,16 @@ public static class ContainerExtensions .AddHttpClient() .AddWorkshopClient() .ConfigureHttpClient(client => client.BaseAddress = new Uri("https://localhost:7281/graphql")); - + + serviceCollection.AddSingleton(r => + { + IHttpClientFactory factory = r.GetRequiredService(); + return new DiscoveryCache(IAuthenticationService.AUTHORITY, () => factory.CreateClient()); + }); + container.WithDependencyInjectionAdapter(serviceCollection); + + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Entities/RefreshTokenEntity.cs b/src/Artemis.WebClient.Workshop/Entities/RefreshTokenEntity.cs new file mode 100644 index 000000000..1abbdfe22 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Entities/RefreshTokenEntity.cs @@ -0,0 +1,7 @@ +namespace Artemis.WebClient.Workshop.Entities; + +public class RefreshTokenEntity +{ + public string RefreshToken { get; set; } + +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWebClientException.cs b/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWebClientException.cs new file mode 100644 index 000000000..1bd2af386 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWebClientException.cs @@ -0,0 +1,24 @@ +using System; + +namespace Artemis.Core; + +/// +/// An exception thrown when a web client related error occurs +/// +public class ArtemisWebClientException : Exception +{ + /// + public ArtemisWebClientException() + { + } + + /// + public ArtemisWebClientException(string? message) : base(message) + { + } + + /// + public ArtemisWebClientException(string? message, Exception? innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Repositories/AuthenticationRepository.cs b/src/Artemis.WebClient.Workshop/Repositories/AuthenticationRepository.cs new file mode 100644 index 000000000..9a3021c1f --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Repositories/AuthenticationRepository.cs @@ -0,0 +1,36 @@ +using Artemis.WebClient.Workshop.Entities; +using LiteDB; + +namespace Artemis.WebClient.Workshop.Repositories; + +internal class AuthenticationRepository : IAuthenticationRepository +{ + private readonly LiteRepository _repository; + + public AuthenticationRepository(LiteRepository repository) + { + _repository = repository; + _repository.Database.GetCollection().EnsureIndex(s => s.RefreshToken); + } + + /// + public void SetRefreshToken(string? refreshToken) + { + _repository.Database.GetCollection().DeleteAll(); + + if (refreshToken != null) + _repository.Insert(new RefreshTokenEntity {RefreshToken = refreshToken}); + } + + /// + public string? GetRefreshToken() + { + return _repository.Query().FirstOrDefault()?.RefreshToken; + } +} + +internal interface IAuthenticationRepository +{ + void SetRefreshToken(string? refreshToken); + string? GetRefreshToken(); +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/AccessToken.cs b/src/Artemis.WebClient.Workshop/Services/AccessToken.cs new file mode 100644 index 000000000..f67dd9395 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/AccessToken.cs @@ -0,0 +1,25 @@ +using Artemis.Core; +using IdentityModel.Client; + +namespace Artemis.WebClient.Workshop.Services; + +internal class AuthenticationToken +{ + public AuthenticationToken(TokenResponse tokenResponse) + { + if (tokenResponse.AccessToken == null) + throw new ArtemisWebClientException("Token response contains no access token"); + if (tokenResponse.RefreshToken == null) + throw new ArtemisWebClientException("Token response contains no refresh token"); + + AccessToken = tokenResponse.AccessToken; + RefreshToken = tokenResponse.RefreshToken; + ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn); + } + + public DateTimeOffset ExpiresAt { get; private set; } + public bool Expired => DateTimeOffset.UtcNow.AddSeconds(5) >= ExpiresAt; + + public string AccessToken { get; private set; } + public string RefreshToken { get; private set; } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs new file mode 100644 index 000000000..c3c112451 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs @@ -0,0 +1,222 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Security.Claims; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.WebClient.Workshop.Repositories; +using IdentityModel; +using IdentityModel.Client; + +namespace Artemis.WebClient.Workshop.Services; + +public interface IAuthenticationService : IProtectedArtemisService +{ + public const string AUTHORITY = "https://localhost:5001"; + + bool IsLoggedIn { get; } + string? UserCode { get; } + ReadOnlyObservableCollection Claims { get; } + + Task GetBearer(); + Task AutoLogin(); + Task Login(); + void Logout(); +} + +internal class AuthenticationService : CorePropertyChanged, IAuthenticationService +{ + internal const string CLIENT_ID = "artemis.desktop"; + + private readonly IDiscoveryCache _discoveryCache; + private readonly IAuthenticationRepository _authenticationRepository; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ObservableCollection _claims = new(); + private readonly SemaphoreSlim _bearerLock = new(1); + + private AuthenticationToken? _token; + private string? _userCode; + private bool _isLoggedIn; + + public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository) + { + _httpClientFactory = httpClientFactory; + _discoveryCache = discoveryCache; + _authenticationRepository = authenticationRepository; + + Claims = new ReadOnlyObservableCollection(_claims); + } + + /// + public bool IsLoggedIn + { + get => _isLoggedIn; + private set => SetAndNotify(ref _isLoggedIn, value); + } + + /// + public string? UserCode + { + get => _userCode; + private set => SetAndNotify(ref _userCode, value); + } + + /// + public ReadOnlyObservableCollection Claims { get; } + + public async Task GetBearer() + { + await _bearerLock.WaitAsync(); + + try + { + // If not logged in, attempt to auto login first + if (!IsLoggedIn) + await AutoLogin(); + + if (_token == null) + return null; + + // If the token is expiring, refresh it + if (_token.Expired && !await UseRefreshToken(_token.RefreshToken)) + return null; + + return _token.AccessToken; + } + finally + { + _bearerLock.Release(); + } + } + + /// + public async Task AutoLogin() + { + if (IsLoggedIn) + return true; + + string? refreshToken = _authenticationRepository.GetRefreshToken(); + if (refreshToken == null) + return false; + + return await UseRefreshToken(refreshToken); + } + + /// + public async Task Login() + { + DiscoveryDocumentResponse disco = await GetDiscovery(); + HttpClient client = _httpClientFactory.CreateClient(); + DeviceAuthorizationResponse response = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest + { + Address = disco.DeviceAuthorizationEndpoint, + ClientId = CLIENT_ID + }); + if (response.IsError) + throw new ArtemisWebClientException("Failed to request device authorization: " + response.Error); + if (response.DeviceCode == null) + throw new ArtemisWebClientException("Failed to request device authorization: Got no device code"); + + DateTimeOffset timeout = DateTimeOffset.UtcNow.AddSeconds(response.ExpiresIn ?? 1800); + + Process.Start(new ProcessStartInfo {FileName = response.VerificationUriComplete, UseShellExecute = true}); + await Task.Delay(TimeSpan.FromSeconds(response.Interval)); + while (DateTimeOffset.UtcNow < timeout) + { + bool success = await AttemptRequestDeviceToken(client, response.DeviceCode); + if (success) + return true; + await Task.Delay(TimeSpan.FromSeconds(response.Interval)); + } + + return false; + } + + /// + public void Logout() + { + _token = null; + _claims.Clear(); + _authenticationRepository.SetRefreshToken(null); + + IsLoggedIn = false; + } + + private async Task GetDiscovery() + { + DiscoveryDocumentResponse disco = await _discoveryCache.GetAsync(); + if (disco.IsError) + throw new ArtemisWebClientException("Failed to retrieve discovery document: " + disco.Error); + + return disco; + } + + private async Task AttemptRequestDeviceToken(HttpClient client, string deviceCode) + { + DiscoveryDocumentResponse disco = await GetDiscovery(); + TokenResponse response = await client.RequestDeviceTokenAsync(new DeviceTokenRequest + { + Address = disco.TokenEndpoint, + ClientId = CLIENT_ID, + DeviceCode = deviceCode + }); + + if (response.IsError) + { + if (response.Error == OidcConstants.TokenErrors.AuthorizationPending || response.Error == OidcConstants.TokenErrors.SlowDown) + return false; + + throw new ArtemisWebClientException("Failed to request device token: " + response.Error); + } + + await SetCurrentUser(client, response); + return true; + } + + private async Task SetCurrentUser(HttpClient client, TokenResponse response) + { + _token = new AuthenticationToken(response); + _authenticationRepository.SetRefreshToken(_token.RefreshToken); + + await GetUserInfo(client, _token.AccessToken); + IsLoggedIn = true; + } + + private async Task GetUserInfo(HttpClient client, string accessToken) + { + DiscoveryDocumentResponse disco = await GetDiscovery(); + UserInfoResponse response = await client.GetUserInfoAsync(new UserInfoRequest() + { + Address = disco.UserInfoEndpoint, + Token = accessToken + }); + if (response.IsError) + throw new ArtemisWebClientException("Failed to retrieve user info: " + response.Error); + + _claims.Clear(); + foreach (Claim responseClaim in response.Claims) + _claims.Add(responseClaim); + } + + private async Task UseRefreshToken(string refreshToken) + { + DiscoveryDocumentResponse disco = await GetDiscovery(); + HttpClient client = _httpClientFactory.CreateClient(); + TokenResponse response = await client.RequestRefreshTokenAsync(new RefreshTokenRequest() + { + Address = disco.TokenEndpoint, + ClientId = CLIENT_ID, + RefreshToken = refreshToken + }); + + if (response.IsError) + { + if (response.Error == OidcConstants.TokenErrors.ExpiredToken) + return false; + + throw new ArtemisWebClientException("Failed to request refresh token: " + response.Error); + } + + await SetCurrentUser(client, response); + return false; + } +} \ No newline at end of file