diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs
index c204c3165..388ebe130 100644
--- a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs
+++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs
@@ -5,9 +5,11 @@ using System.Reactive.Disposables;
using System.Threading;
using System.Threading.Tasks;
using Artemis.UI.Shared;
+using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Services;
using Avalonia.Media.Imaging;
+using FluentAvalonia.UI.Controls;
using Flurl.Http;
using ReactiveUI;
using Serilog;
@@ -16,21 +18,24 @@ namespace Artemis.UI.Screens.Workshop.CurrentUser;
public class CurrentUserViewModel : ActivatableViewModelBase
{
- private readonly ILogger _logger;
private readonly IAuthenticationService _authenticationService;
- private bool _loading = true;
+ private readonly ILogger _logger;
+ private readonly IWindowService _windowService;
private Bitmap? _avatar;
private string? _email;
+ private bool _loading = true;
private string? _name;
private string? _userId;
- public CurrentUserViewModel(ILogger logger, IAuthenticationService authenticationService)
+ public CurrentUserViewModel(ILogger logger, IAuthenticationService authenticationService, IWindowService windowService)
{
_logger = logger;
_authenticationService = authenticationService;
+ _windowService = windowService;
Login = ReactiveCommand.CreateFromTask(ExecuteLogin);
this.WhenActivated(d => ReactiveCommand.CreateFromTask(ExecuteAutoLogin).Execute().Subscribe().DisposeWith(d));
+ this.WhenActivated(d => _authenticationService.IsLoggedIn.Subscribe(_ => LoadCurrentUser().DisposeWith(d)));
}
public bool Loading
@@ -72,8 +77,13 @@ public class CurrentUserViewModel : ActivatableViewModelBase
private async Task ExecuteLogin(CancellationToken cancellationToken)
{
- await _authenticationService.Login();
- await LoadCurrentUser();
+ ContentDialogResult result = await _windowService.CreateContentDialog()
+ .WithViewModel(out WorkshopLoginViewModel _)
+ .WithTitle("Workshop login")
+ .ShowAsync();
+
+ if (result == ContentDialogResult.Primary)
+ await LoadCurrentUser();
}
private async Task ExecuteAutoLogin(CancellationToken cancellationToken)
@@ -95,21 +105,26 @@ public class CurrentUserViewModel : ActivatableViewModelBase
private async Task LoadCurrentUser()
{
- if (!_authenticationService.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);
+ }
+ else
+ {
+ Avatar?.Dispose();
+ Avatar = null;
+ }
}
private async Task LoadAvatar(string userId)
{
try
{
+ Avatar?.Dispose();
Avatar = new Bitmap(await $"{WorkshopConstants.AUTHORITY_URL}/user/avatar/{userId}".GetStreamAsync());
}
catch (Exception)
diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml
new file mode 100644
index 000000000..81b101233
--- /dev/null
+++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml
@@ -0,0 +1,36 @@
+
+
+
+ In order to login you must enter your Artemis credentials in a browser.
+
+
+ If you do not have an account yet you can create one in the browser too. Alternatively you can log in with Google or Discord.
+
+
+
+
+ When logging in please enter the the code below
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs
new file mode 100644
index 000000000..064ae6691
--- /dev/null
+++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Markup.Xaml;
+using Avalonia.ReactiveUI;
+
+namespace Artemis.UI.Screens.Workshop.CurrentUser;
+
+public partial class WorkshopLoginView : ReactiveUserControl
+{
+ public WorkshopLoginView()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
\ No newline at end of file
diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs
new file mode 100644
index 000000000..93f86d1af
--- /dev/null
+++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Linq;
+using System.Reactive;
+using System.Reactive.Disposables;
+using System.Reactive.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Artemis.Core;
+using Artemis.UI.Shared;
+using Artemis.UI.Shared.Services;
+using Artemis.UI.Shared.Services.Builders;
+using Artemis.WebClient.Workshop.Services;
+using Avalonia.Threading;
+using FluentAvalonia.UI.Controls;
+using ReactiveUI;
+
+namespace Artemis.UI.Screens.Workshop.CurrentUser;
+
+public class WorkshopLoginViewModel : ContentDialogViewModelBase
+{
+ private readonly IAuthenticationService _authenticationService;
+ private readonly INotificationService _notificationService;
+ private readonly CancellationTokenSource _cts;
+ private ObservableAsPropertyHelper? _deviceCode;
+ private string? _uri;
+
+ public WorkshopLoginViewModel(IAuthenticationService authenticationService, INotificationService notificationService)
+ {
+ _authenticationService = authenticationService;
+ _notificationService = notificationService;
+ _cts = new CancellationTokenSource();
+
+ OpenBrowser = ReactiveCommand.Create(ExecuteOpenBrowser);
+ CopyToClipboard = ReactiveCommand.CreateFromTask(ExecuteCopyToClipboard);
+
+ this.WhenActivated(d =>
+ {
+ Dispatcher.UIThread.InvokeAsync(Login);
+
+ _deviceCode = _authenticationService.UserCode
+ .Select(uc => uc != null ? string.Join("-", string.Join("-", uc.Chunk(3).Select(c => new string(c)))) : null)
+ .ToProperty(this, vm => vm.Code)
+ .DisposeWith(d);
+ _authenticationService.VerificationUri.Subscribe(u => _uri = u).DisposeWith(d);
+ Disposable.Create(_cts, cts => cts.Cancel()).DisposeWith(d);
+ });
+ }
+
+ public ReactiveCommand OpenBrowser { get; }
+ public ReactiveCommand CopyToClipboard { get; }
+
+ public string? Code => _deviceCode?.Value;
+
+ private async Task Login()
+ {
+ try
+ {
+ bool result = await _authenticationService.Login(_cts.Token);
+ if (result)
+ _notificationService.CreateNotification().WithTitle("Login succeeded").WithSeverity(NotificationSeverity.Success).Show();
+ ContentDialog?.Hide(result ? ContentDialogResult.Primary : ContentDialogResult.Secondary);
+ }
+ catch (Exception e)
+ {
+ if (e is not TaskCanceledException)
+ _notificationService.CreateNotification().WithTitle("Login failed").WithMessage(e.Message).WithSeverity(NotificationSeverity.Error).Show();
+ ContentDialog?.Hide(ContentDialogResult.Secondary);
+ }
+ }
+
+ private void ExecuteOpenBrowser()
+ {
+ if (_uri == null)
+ return;
+ Utilities.OpenUrl(_uri);
+ }
+
+ private async Task ExecuteCopyToClipboard()
+ {
+ if (Code == null)
+ return;
+ await Shared.UI.Clipboard.SetTextAsync(Code);
+ _notificationService.CreateNotification().WithMessage("Code copied to clipboard.").Show();
+ }
+}
\ No newline at end of file
diff --git a/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs
index eed47ecff..2e22afbba 100644
--- a/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs
+++ b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
-using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
using System.Security.Claims;
using Artemis.Core;
using Artemis.Core.Services;
@@ -12,29 +13,31 @@ namespace Artemis.WebClient.Workshop.Services;
public interface IAuthenticationService : IProtectedArtemisService
{
- bool IsLoggedIn { get; }
- string? UserCode { get; }
+ IObservable IsLoggedIn { get; }
+ IObservable UserCode { get; }
+ IObservable VerificationUri { get; }
ReadOnlyObservableCollection Claims { get; }
Task GetBearer();
Task AutoLogin();
- Task Login();
+ Task Login(CancellationToken cancellationToken);
void Logout();
}
internal class AuthenticationService : CorePropertyChanged, IAuthenticationService
{
private const string CLIENT_ID = "artemis.desktop";
+ private readonly IAuthenticationRepository _authenticationRepository;
+ private readonly SemaphoreSlim _bearerLock = new(1);
+ private readonly ObservableCollection _claims = new();
private readonly IDiscoveryCache _discoveryCache;
- private readonly IAuthenticationRepository _authenticationRepository;
private readonly IHttpClientFactory _httpClientFactory;
- private readonly ObservableCollection _claims = new();
- private readonly SemaphoreSlim _bearerLock = new(1);
+ private readonly BehaviorSubject _isLoggedInSubject = new(false);
+ private readonly BehaviorSubject _userCodeSubject = new(null);
+ private readonly BehaviorSubject _verificationUriSubject = new(null);
private AuthenticationToken? _token;
- private string? _userCode;
- private bool _isLoggedIn;
public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository)
{
@@ -45,19 +48,85 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
Claims = new ReadOnlyObservableCollection(_claims);
}
- ///
- public bool IsLoggedIn
+ private async Task GetDiscovery()
{
- get => _isLoggedIn;
- private set => SetAndNotify(ref _isLoggedIn, value);
+ 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, CancellationToken cancellationToken)
+ {
+ DiscoveryDocumentResponse disco = await GetDiscovery();
+ TokenResponse response = await client.RequestDeviceTokenAsync(new DeviceTokenRequest
+ {
+ Address = disco.TokenEndpoint,
+ ClientId = CLIENT_ID,
+ DeviceCode = deviceCode
+ }, cancellationToken);
+
+ 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);
+ }
+
+ SetCurrentUser(response);
+ return true;
+ }
+
+ private void SetCurrentUser(TokenResponse response)
+ {
+ _token = new AuthenticationToken(response);
+ _authenticationRepository.SetRefreshToken(_token.RefreshToken);
+
+ JwtSecurityTokenHandler handler = new();
+ JwtSecurityToken? token = handler.ReadJwtToken(response.IdentityToken);
+ if (token == null)
+ throw new ArtemisWebClientException("Failed to read JWT token");
+
+ _claims.Clear();
+ foreach (Claim responseClaim in token.Claims)
+ _claims.Add(responseClaim);
+
+ _isLoggedInSubject.OnNext(true);
+ }
+
+ 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);
+ }
+
+ SetCurrentUser(response);
+ return false;
}
///
- public string? UserCode
- {
- get => _userCode;
- private set => SetAndNotify(ref _userCode, value);
- }
+ public IObservable IsLoggedIn => _isLoggedInSubject.AsObservable();
+
+ ///
+ public IObservable UserCode => _userCodeSubject.AsObservable();
+
+ ///
+ public IObservable VerificationUri => _verificationUriSubject.AsObservable();
///
public ReadOnlyObservableCollection Claims { get; }
@@ -69,7 +138,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
try
{
// If not logged in, attempt to auto login first
- if (!IsLoggedIn)
+ if (!_isLoggedInSubject.Value)
await AutoLogin();
if (_token == null)
@@ -90,9 +159,9 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
///
public async Task AutoLogin()
{
- if (IsLoggedIn)
+ if (_isLoggedInSubject.Value)
return true;
-
+
string? refreshToken = _authenticationRepository.GetRefreshToken();
if (refreshToken == null)
return false;
@@ -101,34 +170,44 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
}
///
- public async Task Login()
+ public async Task Login(CancellationToken cancellationToken)
{
DiscoveryDocumentResponse disco = await GetDiscovery();
HttpClient client = _httpClientFactory.CreateClient();
DeviceAuthorizationResponse response = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest
{
Address = disco.DeviceAuthorizationEndpoint,
- ClientId = CLIENT_ID,
+ ClientId = CLIENT_ID,
Scope = "openid profile email offline_access api"
- });
+ }, cancellationToken);
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);
+ _userCodeSubject.OnNext(response.UserCode);
+ _verificationUriSubject.OnNext(response.VerificationUri);
- Process.Start(new ProcessStartInfo {FileName = response.VerificationUriComplete, UseShellExecute = true});
- await Task.Delay(TimeSpan.FromSeconds(response.Interval));
- while (DateTimeOffset.UtcNow < timeout)
+ await Task.Delay(TimeSpan.FromSeconds(response.Interval), cancellationToken);
+ try
{
- bool success = await AttemptRequestDeviceToken(client, response.DeviceCode);
- if (success)
- return true;
- await Task.Delay(TimeSpan.FromSeconds(response.Interval));
- }
+ while (DateTimeOffset.UtcNow < timeout)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ bool success = await AttemptRequestDeviceToken(client, response.DeviceCode, cancellationToken);
+ if (success)
+ return true;
+ await Task.Delay(TimeSpan.FromSeconds(response.Interval), cancellationToken);
+ }
- return false;
+ return false;
+ }
+ finally
+ {
+ _userCodeSubject.OnNext(null);
+ _verificationUriSubject.OnNext(null);
+ }
}
///
@@ -137,78 +216,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
_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);
- }
-
- SetCurrentUser(response);
- return true;
- }
-
- private void SetCurrentUser(TokenResponse response)
- {
- _token = new AuthenticationToken(response);
- _authenticationRepository.SetRefreshToken(_token.RefreshToken);
-
- JwtSecurityTokenHandler handler = new();
- JwtSecurityToken? token = handler.ReadJwtToken(response.IdentityToken);
- if (token == null)
- throw new ArtemisWebClientException("Failed to read JWT token");
-
- _claims.Clear();
- foreach (Claim responseClaim in token.Claims)
- _claims.Add(responseClaim);
-
- IsLoggedIn = true;
- }
-
- 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);
- }
-
- SetCurrentUser(response);
- return false;
+ _isLoggedInSubject.OnNext(false);
}
}
\ No newline at end of file