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